@goodandready/dsh-kanban 0.2.24 → 0.2.26

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/client.js CHANGED
@@ -636,12 +636,12 @@ window.__ModuleLoader__.load({
636
636
  {
637
637
  style: {
638
638
  margin: '12px 0', padding: '14px', borderRadius: '8px',
639
- background: 'var(--dsw-alias-state-danger-bg, #ffebe9)',
640
- border: '1px solid var(--dsw-alias-state-danger-border, #ffc1c0)',
639
+ background: 'var(--dsw-alias-state-error-secondary, #ffebe9)',
640
+ border: '1px solid var(--dsw-alias-state-error-secondary, #ffc1c0)',
641
641
  },
642
642
  },
643
643
  h('div', {
644
- style: { fontWeight: 600, marginBottom: '6px', color: 'var(--dsw-alias-state-danger-primary, #cf222e)' },
644
+ style: { fontWeight: 600, marginBottom: '6px', color: 'var(--dsw-alias-state-error-primary, #cf222e)' },
645
645
  }, '⚠️ Kanban UI Error:'),
646
646
  h('div', {
647
647
  style: { fontSize: '12px', wordBreak: 'break-all', color: 'var(--dsw-alias-label-primary)' },
@@ -691,7 +691,7 @@ window.__ModuleLoader__.load({
691
691
 
692
692
 
693
693
  const css =
694
- '.dkb-section-card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:10px;padding:14px 16px;display:flex;flex-direction:column;gap:8px;margin-bottom:12px}' +'.dkb-section-title{font-size:14px;font-weight:600;color:var(--dsw-alias-label-primary);display:flex;align-items:center;justify-content:space-between;gap:8px;padding-bottom:4px;border-bottom:1px solid var(--dsw-alias-border-l2)}' +'.dkb-section-badge{font-size:11px;font-weight:500;padding:2px 8px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary)}' +'.dkb-section-badge-ok{border-color:var(--dsw-alias-state-success-border,#34d399);color:var(--dsw-alias-state-success-primary,#059669);background:var(--dsw-alias-state-success-bg,rgba(16,185,129,0.1))}' +
694
+ '.dkb-section-card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:10px;padding:14px 16px;display:flex;flex-direction:column;gap:8px;margin-bottom:12px}' +'.dkb-section-title{font-size:14px;font-weight:600;color:var(--dsw-alias-label-primary);display:flex;align-items:center;justify-content:space-between;gap:8px;padding-bottom:4px;border-bottom:1px solid var(--dsw-alias-border-l2)}' +'.dkb-section-badge{font-size:11px;font-weight:500;padding:2px 8px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary)}' +'.dkb-section-badge-ok{border-color:var(--dsw-alias-state-success-secondary,#34d399);color:var(--dsw-alias-state-success-primary,#059669);background:var(--dsw-alias-state-success-secondary,rgba(16,185,129,0.1))}' +
695
695
  '.dkb-card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;list-style:none}' +
696
696
  '.dkb-cardOpen{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)}' +
697
697
  '.dkb-page{padding:0}' +
@@ -714,7 +714,7 @@ window.__ModuleLoader__.load({
714
714
  '.dkb-footSplit{justify-content:space-between}' +
715
715
  '.dkb-footLeft{display:flex;align-items:center;gap:8px}' +
716
716
  '.dkb-note{min-width:0;flex:1;margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-secondary)}' +
717
- '.dkb-failed{min-width:0;color:var(--dsw-alias-label-error);flex:1;margin:0;font-size:12px;line-height:1.5}' +
717
+ '.dkb-failed{min-width:0;color:var(--dsw-alias-state-error-primary);flex:1;margin:0;font-size:12px;line-height:1.5}' +
718
718
  '.dkb-save{appearance:none;font:inherit;cursor:pointer;border:1px solid transparent;border-radius:8px;padding:5px 14px;font-size:13px;background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3)}' +
719
719
  '.dkb-discard{appearance:none;font:inherit;cursor:pointer;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:5px 14px;font-size:13px;color:var(--dsw-alias-label-secondary);background:transparent}' +
720
720
  '.dkb-discard:disabled,.dkb-save:disabled{opacity:.4;cursor:default}' +
@@ -757,7 +757,7 @@ window.__ModuleLoader__.load({
757
757
  '.dkb-moveBtn:hover{color:var(--dsw-alias-label-primary)}' +
758
758
  '.dkb-taskCard:focus-visible{outline:2px solid var(--dsw-alias-border-l3);outline-offset:2px}' +
759
759
  '.dkb-sync{color:var(--dsw-alias-label-tertiary);white-space:nowrap;font-size:12px;flex:none}' +
760
- '.dkb-syncBad{color:var(--dsw-alias-status-danger,#e5534b);cursor:help}' +
760
+ '.dkb-syncBad{color:var(--dsw-alias-state-error-primary,#e5534b);cursor:help}' +
761
761
  '.dkb-facet{position:relative;flex:none}' +
762
762
  '.dkb-facetHead{appearance:none;font:inherit;cursor:pointer;color:var(--dsw-alias-label-secondary);background:0 0;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:4px 10px;font-size:13px;white-space:nowrap}' +
763
763
  '.dkb-facetHead:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)}' +
@@ -792,7 +792,7 @@ window.__ModuleLoader__.load({
792
792
  '.dkb-count[data-col=review]{background:var(--dsw-alias-state-business-primary)}' +
793
793
  '.dkb-count[data-col=deploy]{background:var(--dsw-alias-state-business-primary)}' +
794
794
  '.dkb-count[data-col=done]{background:var(--dsw-alias-state-success-primary)}' +
795
- '.dkb-countOver{background:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-label-inverse,#fff)}' +
795
+ '.dkb-countOver{background:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-label-primary-inverted,#fff)}' +
796
796
  '.dkb-overdue{border-color:var(--dsw-alias-state-error-primary)}' +
797
797
  '.dkb-dueOver{color:var(--dsw-alias-state-error-primary);font-weight:600}' +
798
798
  '.dkb-list{flex-direction:column;flex:1;gap:8px;min-height:0;padding:2px 8px 10px;display:flex;overflow-y:auto}' +
@@ -837,7 +837,7 @@ window.__ModuleLoader__.load({
837
837
  '.dkb-waiting{color:var(--dsw-alias-state-warn-primary);font-weight:600}' +
838
838
  '.dkb-waitDot{border-radius:50%;flex:none;width:8px;height:8px;background:var(--dsw-alias-state-warn-primary);display:inline-block}' +
839
839
  '.dkb-tag{white-space:nowrap;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-interactive-bg-hover);border:1px solid transparent;border-radius:999px;padding:1px 7px;font-size:11px;line-height:1.5;font-weight:500}' +
840
- '.dkb-modal{position:absolute;inset:0;background:var(--dsw-alias-mask-bg,rgba(0,0,0,.4));display:flex;align-items:center;justify-content:center;padding:24px 24px 84px;box-sizing:border-box;z-index:70}' +
840
+ '.dkb-modal{position:absolute;inset:0;background:var(--dsw-alias-bg-mask-1,rgba(0,0,0,.4));display:flex;align-items:center;justify-content:center;padding:24px 24px 84px;box-sizing:border-box;z-index:70}' +
841
841
  '.dkb-panel{width:min(720px,100%);max-height:100%;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);border-radius:12px;padding:16px;box-sizing:border-box;overflow-y:auto;display:flex;flex-direction:column;gap:12px}' +
842
842
  '@media (min-width: 960px){.dkb-panelWide{width:min(1100px,96%)}.dkb-drawerGrid{display:grid;grid-template-columns:300px 1fr;gap:20px;align-items:start}}' +
843
843
  '.dkb-drawerSidebar{display:flex;flex-direction:column;gap:12px;border-right:1px solid var(--dsw-alias-border-l2);padding-right:16px}' +
@@ -845,7 +845,7 @@ window.__ModuleLoader__.load({
845
845
  '.dkb-panelBody{white-space:pre-wrap;color:var(--dsw-alias-label-secondary);font-size:13px;line-height:1.5;margin:0}' +
846
846
  '.dkb-log{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px}' +
847
847
  '.dkb-logRow{color:var(--dsw-alias-label-secondary);font-size:12px}' +
848
- '.dkb-dialog{position:absolute;inset:0;background:var(--dsw-alias-mask-bg,rgba(0,0,0,.4));display:flex;align-items:center;justify-content:center;z-index:80}' +
848
+ '.dkb-dialog{position:absolute;inset:0;background:var(--dsw-alias-bg-mask-1,rgba(0,0,0,.4));display:flex;align-items:center;justify-content:center;z-index:80}' +
849
849
  '.dkb-dialogBox{width:min(560px,94%);max-height:86%;overflow-y:auto;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);border-radius:12px;padding:16px;display:flex;flex-direction:column;gap:10px}' +
850
850
  '.dkb-issue{appearance:none;font:inherit;text-align:left;cursor:pointer;background:0 0;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:8px 10px;color:var(--dsw-alias-label-primary);font-size:13px}' +
851
851
  '.dkb-issueUsed{opacity:.5}' +
@@ -859,11 +859,11 @@ window.__ModuleLoader__.load({
859
859
  '.dkb-quickFilterBar{display:flex;gap:6px;align-items:center;flex-wrap:wrap;padding:4px 0}' +
860
860
  '.dkb-filterChip{appearance:none;font:inherit;cursor:pointer;background:var(--dsw-alias-interactive-bg-hover);border:1px solid var(--dsw-alias-border-l2);border-radius:999px;padding:3px 10px;font-size:12px;color:var(--dsw-alias-label-secondary);transition:all .15s}' +
861
861
  '.dkb-filterChip:hover{background:var(--dsw-alias-interactive-bg-active);color:var(--dsw-alias-label-primary)}' +
862
- '.dkb-filterChipActive{background:var(--dsw-alias-state-business-primary);color:var(--dsw-alias-label-inverse,#fff);border-color:transparent;font-weight:600}' +
863
- '.dkb-epicTag{background:var(--dsw-alias-state-info-bg,#ddf4ff);color:var(--dsw-alias-state-info-primary,#0969da);font-weight:600}' +
862
+ '.dkb-filterChipActive{background:var(--dsw-alias-state-business-primary);color:var(--dsw-alias-label-primary-inverted,#fff);border-color:transparent;font-weight:600}' +
863
+ '.dkb-epicTag{background:color-mix(in srgb, var(--dsw-alias-state-business-primary) 12%, transparent);color:var(--dsw-alias-state-business-primary,#0969da);font-weight:600}' +
864
864
  '.dkb-progressBar{height:4px;background:var(--dsw-alias-border-l2);border-radius:2px;overflow:hidden;margin:4px 0}' +
865
865
  '.dkb-progressFill{height:100%;background:var(--dsw-alias-state-success-primary,#16a34a);transition:width .2s}' +
866
- '.dkb-consolePre{background:var(--dsw-alias-bg-layer-1,#0d1117);color:var(--dsw-alias-state-info-primary,#58a6ff);border:1px solid var(--dsw-alias-border-l2);padding:12px;border-radius:8px;font-family:monospace;font-size:11px;max-height:260px;overflow-y:auto;white-space:pre-wrap}'
866
+ '.dkb-consolePre{background:var(--dsw-alias-bg-layer-1,#0d1117);color:var(--dsw-alias-state-business-primary,#58a6ff);border:1px solid var(--dsw-alias-border-l2);padding:12px;border-radius:8px;font-family:monospace;font-size:11px;max-height:260px;overflow-y:auto;white-space:pre-wrap}'
867
867
 
868
868
 
869
869
 
@@ -2064,7 +2064,7 @@ window.__ModuleLoader__.load({
2064
2064
  : null,
2065
2065
  task.cron ? h('span', { className: 'dkb-tag', title: task.cron }, '⏰ cron') : null,
2066
2066
  task.progressDump ? h('span', { className: 'dkb-tag', title: 'PROGRESSDUMP' }, '📋 dump') : null,
2067
- task.rejectReason ? h('span', { className: 'dkb-tag', style: { background: 'var(--dsw-alias-state-danger-bg, #ffebe9)', color: 'var(--dsw-alias-state-danger-primary, #cf222e)', borderColor: 'var(--dsw-alias-state-danger-border, #ffc1c0)' }, title: task.rejectReason }, '↩ ' + t('acceptance.rejectedBadge')) : null,
2067
+ task.rejectReason ? h('span', { className: 'dkb-tag', style: { background: 'var(--dsw-alias-state-error-secondary, #ffebe9)', color: 'var(--dsw-alias-state-error-primary, #cf222e)', borderColor: 'var(--dsw-alias-state-error-secondary, #ffc1c0)' }, title: task.rejectReason }, '↩ ' + t('acceptance.rejectedBadge')) : null,
2068
2068
  Array.isArray(task.checklist) && task.checklist.length > 0
2069
2069
  ? h('span', { className: 'dkb-tag', title: 'DoD Checklist' }, `☑ ${task.checklist.filter((i) => i.completed).length}/${task.checklist.length}`)
2070
2070
  : null,
@@ -3001,7 +3001,7 @@ window.__ModuleLoader__.load({
3001
3001
  openTask.permission && !openTask.permissionConfirmedAt
3002
3002
  ? h('div', {
3003
3003
  className: 'dkb-warnBox',
3004
- style: { margin: '4px 0', padding: 10, background: 'var(--dsw-alias-state-warn-bg, #fff3cd)', border: '1px solid var(--dsw-alias-state-warn-border, #ffeeba)', borderRadius: 6 },
3004
+ style: { margin: '4px 0', padding: 10, background: 'var(--dsw-alias-state-warn-secondary, #fff3cd)', border: '1px solid var(--dsw-alias-state-warn-secondary, #ffeeba)', borderRadius: 6 },
3005
3005
  },
3006
3006
  h('div', { style: { marginBottom: 6, fontSize: 13, fontWeight: 500, color: 'var(--dsw-alias-state-warn-primary, #856404)' } },
3007
3007
  t('task.permissionGateWarning', { permission: openTask.permission })),
@@ -3308,13 +3308,13 @@ window.__ModuleLoader__.load({
3308
3308
  className: 'dkb-warnBox',
3309
3309
  style: {
3310
3310
  margin: '4px 0', padding: 10,
3311
- background: 'var(--dsw-alias-state-danger-bg, #ffebe9)',
3312
- border: '1px solid var(--dsw-alias-state-danger-border, #ffc1c0)',
3311
+ background: 'var(--dsw-alias-state-error-secondary, #ffebe9)',
3312
+ border: '1px solid var(--dsw-alias-state-error-secondary, #ffc1c0)',
3313
3313
  borderRadius: 6,
3314
3314
  },
3315
3315
  },
3316
3316
  h('div', {
3317
- style: { marginBottom: 4, fontSize: 13, fontWeight: 600, color: 'var(--dsw-alias-state-danger-primary, #cf222e)' },
3317
+ style: { marginBottom: 4, fontSize: 13, fontWeight: 600, color: 'var(--dsw-alias-state-error-primary, #cf222e)' },
3318
3318
  }, '⚠️ ' + t('acceptance.rejectedAlert', { reason: openTask.rejectReason })),
3319
3319
  )
3320
3320
  : null,
@@ -3349,7 +3349,7 @@ window.__ModuleLoader__.load({
3349
3349
  key: cm.hash,
3350
3350
  style: { fontSize: 11, display: 'flex', gap: 6, opacity: 0.85, fontFamily: 'monospace' },
3351
3351
  },
3352
- h('span', { style: { color: 'var(--dsw-alias-label-link, #3b82f6)' } }, cm.hash),
3352
+ h('span', { style: { color: 'var(--dsw-alias-link, #3b82f6)' } }, cm.hash),
3353
3353
  h('span', { style: { flex: 1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' } }, cm.subject),
3354
3354
  h('span', { style: { opacity: 0.6 } }, cm.date),
3355
3355
  ))
@@ -3386,7 +3386,7 @@ window.__ModuleLoader__.load({
3386
3386
  ? h('button', {
3387
3387
  type: 'button', className: 'dkb-save',
3388
3388
  disabled: merging,
3389
- style: { background: 'var(--dsw-alias-state-success-primary, #16a34a)', color: 'var(--dsw-alias-label-inverse, #fff)' },
3389
+ style: { background: 'var(--dsw-alias-state-success-primary, #16a34a)', color: 'var(--dsw-alias-label-primary-inverted, #fff)' },
3390
3390
  onClick: async () => {
3391
3391
  try {
3392
3392
  setMerging(true)
@@ -3416,7 +3416,7 @@ window.__ModuleLoader__.load({
3416
3416
  ? h('button', {
3417
3417
  type: 'button', className: 'dkb-save',
3418
3418
  disabled: prCreating,
3419
- style: { background: 'var(--dsw-alias-state-info-primary, #0969da)', color: 'var(--dsw-alias-label-inverse, #fff)' },
3419
+ style: { background: 'var(--dsw-alias-state-business-primary, #0969da)', color: 'var(--dsw-alias-label-primary-inverted, #fff)' },
3420
3420
  onClick: async () => {
3421
3421
  setPrCreating(true)
3422
3422
  try {
@@ -3434,7 +3434,7 @@ window.__ModuleLoader__.load({
3434
3434
  createdPr
3435
3435
  ? h('a', {
3436
3436
  className: 'dkb-save',
3437
- style: { background: 'var(--dsw-alias-state-success-primary, #16a34a)', color: 'var(--dsw-alias-label-inverse, #fff)', textDecoration: 'none', display: 'inline-flex', alignItems: 'center' },
3437
+ style: { background: 'var(--dsw-alias-state-success-primary, #16a34a)', color: 'var(--dsw-alias-label-primary-inverted, #fff)', textDecoration: 'none', display: 'inline-flex', alignItems: 'center' },
3438
3438
  href: createdPr.html_url,
3439
3439
  target: '_blank', rel: 'noopener noreferrer',
3440
3440
  }, '↗ ' + t('pr.view') + ` #${createdPr.number}`)
package/lib/import.js CHANGED
@@ -97,10 +97,18 @@ export function issueToTask(issue, { board = 'main', column = 'backlog', owner,
97
97
  * Compared by owner-repo-number tuple to avoid colliding with issue #1 in another repo.
98
98
  * Existing issues are dimmed rather than hidden, as re-importing may be intentional.
99
99
  */
100
- export function markImported(issues, existing, { owner, repo }) {
100
+ export function markImported(issues, existing, { owner, repo, forge }) {
101
+ const targetForge = forge || ''
101
102
  const seen = new Set(
102
103
  (existing ?? [])
103
- .filter((t) => t.owner === owner && t.repo === repo && typeof t.issueNumber === 'number')
104
+ .filter((t) => {
105
+ if (t.owner !== owner || t.repo !== repo || typeof t.issueNumber !== 'number') return false
106
+ if (targetForge) {
107
+ const tf = t.forge || (typeof t.issueUrl === 'string' && t.issueUrl.includes('github.com') ? 'github' : 'gitea')
108
+ return tf === targetForge
109
+ }
110
+ return true
111
+ })
104
112
  .map((t) => t.issueNumber),
105
113
  )
106
114
  return (issues ?? []).map((issue) => ({ ...issue, imported: seen.has(issue?.number) }))
@@ -158,7 +166,7 @@ export async function listImportable({ gitea, store, owner, repo, board = 'main'
158
166
  if (state !== 'ready') return { error: `gitea-${state}`, status: 409 }
159
167
  const issues = await gitea.listIssues({ owner, repo, state: 'open', provider: forge || provider })
160
168
  const existing = store.listTasks({ board })
161
- return { issues: markImported(issues, existing, { owner, repo }) }
169
+ return { issues: markImported(issues, existing, { owner, repo, forge: forge || provider }) }
162
170
  }
163
171
 
164
172
  /** Create a task from an issue. */
package/lib/index.js CHANGED
@@ -119,18 +119,6 @@ export const SETTINGS_NAMESPACE = 'dsh-kanban'
119
119
  * Scalar values only: client config API only writes scalar fields;
120
120
  * dictionaries or arrays here would make the settings card uneditable.
121
121
  */
122
- // Ensure .volatile() helper exists on Schemastery schema prototype
123
- try {
124
- const schemaProto = Object.getPrototypeOf(z.boolean())
125
- if (schemaProto && typeof schemaProto.volatile !== 'function') {
126
- schemaProto.volatile = function () {
127
- return typeof this.extra === 'function' ? this.extra('volatile', true) : this
128
- }
129
- }
130
- } catch {
131
- // safe fallback
132
- }
133
-
134
122
  export const Config = z.object(Object.fromEntries(
135
123
  Object.entries(CONFIG_DEFAULTS).map(([field, fallback]) => {
136
124
  // Node type derived from default value type.
@@ -1243,6 +1231,9 @@ export function apply(ctx, config) {
1243
1231
  }
1244
1232
  if (req.method === 'POST' && action === 'comments') {
1245
1233
  const resObj = handleAddTaskComment({ store, task: store.getTask(id), input })
1234
+ if (resObj?.body?.task || resObj?.status === 200) {
1235
+ eventHub.broadcast('task:change', { taskId: id, action: 'updated', task: store.getTask(id) })
1236
+ }
1246
1237
  writeJson(res, resObj.status, resObj.body)
1247
1238
  return
1248
1239
  }
@@ -1362,7 +1353,11 @@ export function apply(ctx, config) {
1362
1353
  return
1363
1354
  }
1364
1355
  if (req.method === 'POST' && action === 'note') {
1365
- reply(res, appendNote({ store, id, input }))
1356
+ const out = appendNote({ store, id, input })
1357
+ if (out?.task || out?.text) {
1358
+ eventHub.broadcast('task:change', { taskId: id, action: 'updated', task: store.getTask(id) })
1359
+ }
1360
+ reply(res, out)
1366
1361
  return
1367
1362
  }
1368
1363
  if (req.method === 'PATCH' && (action === undefined || action === '')) {
package/lib/intake.js CHANGED
@@ -41,7 +41,7 @@ export function watchedRepos({ config, owner }) {
41
41
  *
42
42
  * @returns {{take: boolean, why: string}} `why` states reason on rejection.
43
43
  */
44
- export function shouldTake({ store, owner, repo, issue }) {
44
+ export function shouldTake({ store, owner, repo, issue, forge }) {
45
45
  const number = issue?.number
46
46
  if (typeof number !== 'number') return { take: false, why: 'missing-number' }
47
47
  // Pull request in Gitea is also an issue, but not a standalone board card:
@@ -50,10 +50,10 @@ export function shouldTake({ store, owner, repo, issue }) {
50
50
  return { take: false, why: 'pull-request-ignored' }
51
51
  }
52
52
  if (issue?.state === 'closed') return { take: false, why: 'issue-closed' }
53
- if (store.findTaskByIssue({ owner, repo, issueNumber: number }) !== undefined) {
53
+ if (store.findTaskByIssue({ owner, repo, issueNumber: number, forge }) !== undefined) {
54
54
  return { take: false, why: 'already-exists' }
55
55
  }
56
- if (store.isDismissed({ owner, repo, issueNumber: number })) {
56
+ if (store.isDismissed({ owner, repo, issueNumber: number, forge })) {
57
57
  return { take: false, why: 'tombstone-ignored' }
58
58
  }
59
59
  return { take: true, why: '' }
package/lib/launcher.js CHANGED
@@ -163,7 +163,7 @@ export async function obtainAgent({
163
163
  agentPreset, permission, permissions, gitRunner, workspaceRegistry,
164
164
  reasoningEffort,
165
165
  }) {
166
- const chosenPerm = permission || task?.permission || (typeof permissions?.set === 'function' ? (config?.sessionDefaultPermission || '') : '')
166
+ const chosenPerm = permission || task?.permission || config?.sessionDefaultPermission || ""
167
167
  const effort = reasoningEffort || task?.reasoningEffort || undefined
168
168
  const agentOptions = { provider, model, ...(effort ? { reasoningEffort: effort } : {}) }
169
169
 
@@ -341,6 +341,9 @@ export async function runTask({
341
341
  }) {
342
342
  const effort = reasoningEffort || task?.reasoningEffort || undefined
343
343
 
344
+ // Gate check must reject unconfirmed elevated tasks before queueing or state changes (#328)
345
+ verifyPermissionGate({ task, permission, config })
346
+
344
347
  // Concurrency limit check (#214, #330)
345
348
  const maxConcurrent = typeof config?.maxConcurrentSessions === 'number' ? config.maxConcurrentSessions : 3
346
349
  if (maxConcurrent > 0) {
@@ -370,8 +373,6 @@ export async function runTask({
370
373
  }
371
374
  }
372
375
 
373
- verifyPermissionGate({ task, permission, config })
374
-
375
376
  reserveSessionSlot(task.id)
376
377
  let taken
377
378
  try {
@@ -662,7 +663,12 @@ export function unqueueTask({ store, task }) {
662
663
  */
663
664
  export function countActiveSessions({ store, agents }) {
664
665
  const sessions = liveSessions({ store, agents })
665
- const activeCount = sessions.filter((s) => s.status === 'running' || s.status === 'busy' || s.status === 'thinking').length
666
+ const activeCount = sessions.filter((s) =>
667
+ s.status === 'running' ||
668
+ s.status === 'busy' ||
669
+ s.status === 'thinking' ||
670
+ s.tasks.some((t) => t.workflowActive)
671
+ ).length
666
672
  return activeCount + activeReservations.size
667
673
  }
668
674
 
@@ -689,10 +695,12 @@ export function liveSessions({ store, agents }) {
689
695
  if (live.origin === 'subagent' || live.parentId !== undefined || live.meta?.origin === 'subagent' || live.isSubagent) {
690
696
  continue
691
697
  }
698
+ const isWorkflowActive = task.workflowStatus === 'running' || (typeof task.workflowRunId === 'string' && task.workflowRunId !== '' && task.workflowStatus !== 'completed' && task.workflowStatus !== 'failed' && task.workflowStatus !== 'cancelled')
692
699
  if (!byId.has(id)) byId.set(id, { sessionId: id, status: live.status, tasks: [] })
693
700
  byId.get(id).tasks.push({
694
701
  id: task.id, title: task.title, repo: task.repo, issueNumber: task.issueNumber,
695
702
  queued: typeof task.queuedAt === 'number' && task.queuedAt > 0,
703
+ workflowActive: isWorkflowActive,
696
704
  })
697
705
  }
698
706
  return [...byId.values()]
@@ -715,34 +723,36 @@ export async function dispatchNextQueuedTask({
715
723
  const queued = typeof store.listQueuedTasks === 'function' ? store.listQueuedTasks() : []
716
724
  if (queued.length === 0) return null
717
725
 
718
- const nextTask = queued[0]
719
- let queuedParams = {}
720
- if (typeof nextTask.queuedRequest === 'string' && nextTask.queuedRequest) {
721
- try { queuedParams = JSON.parse(nextTask.queuedRequest) } catch { /* ignore */ }
722
- }
723
- try {
724
- return await runTask({
725
- agents,
726
- store,
727
- task: nextTask,
728
- config,
729
- provider: queuedParams.provider || nextTask.provider || undefined,
730
- model: queuedParams.model || nextTask.model || undefined,
731
- permission: queuedParams.permission || nextTask.permission || undefined,
732
- reasoningEffort: queuedParams.reasoningEffort || nextTask.reasoningEffort || undefined,
733
- text: queuedParams.text || undefined,
734
- agentPreset: queuedParams.agentPreset || agentPreset,
735
- mintSessionId: mintSessionId || (() => `kanban-${nextTask.id}-${randomUUID()}`),
736
- createMessage: createMessage || ((msg) => msg),
737
- cwdOf,
738
- logger,
739
- permissions,
740
- workspaceRegistry,
741
- claimAs: claimAs || 'queue-dispatcher',
742
- gitRunner,
743
- })
744
- } catch (err) {
745
- logger?.warn?.(`dsh-kanban: failed to start queued task ${nextTask.id}: ${err?.message}`)
746
- return null
726
+ for (const nextTask of queued) {
727
+ let queuedParams = {}
728
+ if (typeof nextTask.queuedRequest === 'string' && nextTask.queuedRequest) {
729
+ try { queuedParams = JSON.parse(nextTask.queuedRequest) } catch { /* ignore */ }
730
+ }
731
+ try {
732
+ return await runTask({
733
+ agents,
734
+ store,
735
+ task: nextTask,
736
+ config,
737
+ provider: queuedParams.provider || nextTask.provider || undefined,
738
+ model: queuedParams.model || nextTask.model || undefined,
739
+ permission: queuedParams.permission || nextTask.permission || undefined,
740
+ reasoningEffort: queuedParams.reasoningEffort || nextTask.reasoningEffort || undefined,
741
+ text: queuedParams.text || undefined,
742
+ agentPreset: queuedParams.agentPreset || agentPreset,
743
+ mintSessionId: mintSessionId || (() => `kanban-${nextTask.id}-${randomUUID()}`),
744
+ createMessage: createMessage || ((msg) => msg),
745
+ cwdOf,
746
+ logger,
747
+ permissions,
748
+ workspaceRegistry,
749
+ claimAs: claimAs || 'queue-dispatcher',
750
+ gitRunner,
751
+ })
752
+ } catch (err) {
753
+ logger?.warn?.(`dsh-kanban: failed to start queued task ${nextTask.id}: ${err?.message}`)
754
+ unqueueTask({ store, task: nextTask })
755
+ }
747
756
  }
757
+ return null
748
758
  }
package/lib/routes.js CHANGED
@@ -34,35 +34,61 @@ function isLoopbackAddress(addr) {
34
34
  export function isTrustedRequest(req) {
35
35
  if (!req || !req.headers) return false
36
36
 
37
- // 1. Verify sec-fetch-site header (missing or cross-site rejected)
37
+ const remote = req.socket?.remoteAddress || req.connection?.remoteAddress
38
38
  const site = req.headers['sec-fetch-site']
39
- if (!site || site === 'cross-site') {
40
- return false
41
- }
42
- if (site !== 'same-origin' && site !== 'same-site' && site !== 'none') {
39
+
40
+ // 1. Explicit cross-site or same-site requests are rejected
41
+ if (site === 'cross-site' || site === 'same-site') {
43
42
  return false
44
43
  }
45
44
 
46
- // 2. Validate origin against host
47
- const origin = req.headers.origin
48
- if (origin !== undefined) {
49
- if (origin === 'null') return false
50
- try {
51
- const parsed = new URL(origin)
52
- const host = req.headers.host || ''
53
- if (parsed.host && parsed.host !== host) return false
54
- } catch {
45
+ // 2. Browser request with declared site:
46
+ // Must be same-origin or none (e.g. top-level navigation / bookmark / isolated subresource)
47
+ if (site === 'same-origin' || site === 'none') {
48
+ const origin = req.headers.origin
49
+ if (origin !== undefined) {
50
+ if (origin === 'null') return false
51
+ try {
52
+ const parsed = new URL(origin)
53
+ const host = req.headers.host || ''
54
+ if (parsed.host && parsed.host !== host) return false
55
+ } catch {
56
+ return false
57
+ }
58
+ }
59
+ // Remote client must be loopback if present
60
+ if (remote && !isLoopbackAddress(remote)) {
55
61
  return false
56
62
  }
63
+ return true
57
64
  }
58
65
 
59
- // 3. Verify loopback remoteAddress
60
- const remote = req.socket?.remoteAddress || req.connection?.remoteAddress
61
- if (remote && !isLoopbackAddress(remote)) {
62
- return false
66
+ // 3. Request without declared site:
67
+ // Allowed strictly for loopback remoteAddress (DSH Desktop Electron forwardWebRequest, local CLI, curl)
68
+ if (remote && isLoopbackAddress(remote)) {
69
+ const origin = req.headers.origin
70
+ if (origin !== undefined) {
71
+ if (origin === 'null') return false
72
+ try {
73
+ const parsed = new URL(origin)
74
+ const host = req.headers.host || ''
75
+ if (parsed.host && parsed.host !== host) return false
76
+ } catch {
77
+ return false
78
+ }
79
+ }
80
+ return true
63
81
  }
64
82
 
65
- return true
83
+ // Fallback for test harnesses / mocks without socket where host header targets loopback
84
+ if (!remote && req.headers.host) {
85
+ const hostName = String(req.headers.host).split(':')[0]
86
+ if (isLoopbackAddress(hostName)) {
87
+ return true
88
+ }
89
+ }
90
+
91
+ return false
66
92
  }
67
93
 
68
94
  /** Parse `/dsh-kanban/task/<id>[/<action>]`; returns undefined if non-matching. */
@@ -412,7 +438,13 @@ export function importBoard({ store, input }) {
412
438
  const doImport = () => {
413
439
  for (const board of Array.isArray(input.boards) ? input.boards : []) {
414
440
  if (typeof board?.id !== 'string' || board.id === '') continue
415
- store.createBoard({ id: board.id, title: board.title, kind: board.kind })
441
+ store.createBoard({
442
+ id: board.id,
443
+ title: board.title,
444
+ kind: board.kind,
445
+ position: board.position,
446
+ createdAt: board.createdAt,
447
+ })
416
448
  }
417
449
 
418
450
  for (const task of input.tasks) {
package/lib/store.js CHANGED
@@ -94,8 +94,9 @@ CREATE TABLE IF NOT EXISTS dismissed (
94
94
  owner TEXT NOT NULL,
95
95
  repo TEXT NOT NULL,
96
96
  issueNumber INTEGER NOT NULL,
97
+ forge TEXT NOT NULL DEFAULT '',
97
98
  at INTEGER NOT NULL,
98
- PRIMARY KEY (owner, repo, issueNumber)
99
+ PRIMARY KEY (owner, repo, issueNumber, forge)
99
100
  );
100
101
  `
101
102
 
@@ -301,6 +302,21 @@ export function openStore({ dir }) {
301
302
  try { db.exec('ROLLBACK') } catch { /* ignore */ }
302
303
  }
303
304
  }
305
+ // Ensure board order keys are valid, even if fractional_indexing_migrated marker already exists (#345)
306
+ const boardRows = db.prepare('SELECT id, position FROM boards ORDER BY position ASC, id ASC').all()
307
+ if (boardRows.length > 0 && boardRows.some((b) => !isValidOrderKey(b.position))) {
308
+ try {
309
+ db.exec('BEGIN TRANSACTION')
310
+ const updateBoardPos = db.prepare('UPDATE boards SET position = ? WHERE id = ?')
311
+ const newBoardKeys = generateNKeysBetween(null, null, boardRows.length)
312
+ for (let i = 0; i < boardRows.length; i++) {
313
+ updateBoardPos.run(newBoardKeys[i], boardRows[i].id)
314
+ }
315
+ db.exec('COMMIT')
316
+ } catch {
317
+ try { db.exec('ROLLBACK') } catch { /* ignore */ }
318
+ }
319
+ }
304
320
  db.exec('PRAGMA synchronous = NORMAL')
305
321
  try { db.exec('PRAGMA optimize') } catch { /* pragma optimize best-effort */ }
306
322
 
@@ -357,7 +373,7 @@ export function openStore({ dir }) {
357
373
  archived: db.prepare('SELECT * FROM tasks WHERE archivedAt > 0 ORDER BY archivedAt DESC'),
358
374
  archivable: db.prepare(
359
375
  "SELECT * FROM tasks WHERE archivedAt = 0 AND col = 'done' AND columnAt > 0 AND columnAt <= ?"),
360
- dismiss: db.prepare('INSERT OR REPLACE INTO dismissed (owner, repo, issueNumber, at) VALUES (?, ?, ?, ?)'),
376
+ dismiss: db.prepare('INSERT OR REPLACE INTO dismissed (owner, repo, issueNumber, forge, at) VALUES (?, ?, ?, ?, ?)'),
361
377
  isDismissed: db.prepare('SELECT 1 FROM dismissed WHERE owner = ? AND repo = ? AND issueNumber = ?'),
362
378
  isDismissedWithForge: db.prepare(`SELECT 1 FROM dismissed WHERE owner = ? AND repo = ? AND issueNumber = ? AND (forge = ? OR (forge = '' AND ? = 'gitea'))`),
363
379
  byIssue: db.prepare('SELECT * FROM tasks WHERE owner = ? AND repo = ? AND issueNumber = ?'),
@@ -378,12 +394,19 @@ export function openStore({ dir }) {
378
394
  "SELECT COUNT(*) AS total, SUM(CASE WHEN col = 'done' THEN 1 ELSE 0 END) AS completed FROM tasks WHERE parentId = ? AND archivedAt = 0"),
379
395
  }
380
396
 
381
- function ensureBoard(id, title, kind) {
397
+ function ensureBoard(id, title, kind, position, createdAt) {
382
398
  const found = q.boardById.get(id)
383
- if (found !== undefined) return found
399
+ if (found !== undefined) {
400
+ if (position !== undefined || createdAt !== undefined || title !== undefined || kind !== undefined) {
401
+ db.prepare('UPDATE boards SET title = ?, kind = ?, position = ?, createdAt = ? WHERE id = ?')
402
+ .run(title ?? found.title, normalizeKind(kind ?? found.kind), position ?? found.position, createdAt ?? found.createdAt, id)
403
+ }
404
+ return q.boardById.get(id)
405
+ }
384
406
  const last = q.lastBoard.get()
385
- const position = keyBetween(last?.position, undefined)
386
- q.insertBoard.run(id, title, normalizeKind(kind), position, Date.now())
407
+ const pos = isValidOrderKey(position) ? position : keyBetween(last?.position, undefined)
408
+ const created = typeof createdAt === 'number' && createdAt > 0 ? createdAt : Date.now()
409
+ q.insertBoard.run(id, title, normalizeKind(kind), pos, created)
387
410
  return q.boardById.get(id)
388
411
  }
389
412
 
@@ -453,8 +476,8 @@ export function openStore({ dir }) {
453
476
  return q.boards.all()
454
477
  },
455
478
 
456
- createBoard({ id, title, kind }) {
457
- return ensureBoard(id, title ?? id, kind)
479
+ createBoard({ id, title, kind, position, createdAt }) {
480
+ return ensureBoard(id, title ?? id, kind, position, createdAt)
458
481
  },
459
482
 
460
483
  /** Board kind. Unrecognized kinds default to 'project'. */
@@ -646,7 +669,9 @@ export function openStore({ dir }) {
646
669
  const checklist = JSON.stringify(Array.isArray(task.checklist) ? task.checklist : [])
647
670
  const report = JSON.stringify(task.report && typeof task.report === 'object' ? task.report : {})
648
671
  const comments = JSON.stringify(Array.isArray(task.comments) ? task.comments : [])
649
- const forge = task.forge || (typeof task.issueUrl === 'string' && task.issueUrl.includes('github.com') ? 'github' : 'gitea')
672
+ const forge = typeof task.forge === 'string'
673
+ ? task.forge
674
+ : (typeof task.issueUrl === 'string' && task.issueUrl.includes('github.com') ? 'github' : (task.owner && task.repo && task.issueNumber ? 'gitea' : ''))
650
675
  const targetBoard = task.board || 'main'
651
676
  const targetCol = task.column || task.col || 'backlog'
652
677
  const position = isValidOrderKey(task.position) ? task.position : positionFor(targetBoard, targetCol, undefined, undefined)
@@ -774,10 +799,10 @@ export function openStore({ dir }) {
774
799
  },
775
800
 
776
801
  deleteTask(id) {
777
- // Record deleted Gitea tasks as dismissed to prevent the intake loop from re-importing them.
802
+ // Record deleted tasks as dismissed to prevent the intake loop from re-importing them.
778
803
  const task = q.taskById.get(id)
779
804
  if (task?.owner && task?.repo && typeof task.issueNumber === 'number') {
780
- q.dismiss.run(task.owner, task.repo, task.issueNumber, Date.now())
805
+ q.dismiss.run(task.owner, task.repo, task.issueNumber, task.forge || '', Date.now())
781
806
  }
782
807
  q.deleteTask.run(id)
783
808
  },
package/lib/sync.js CHANGED
@@ -40,9 +40,10 @@ export async function withBackoff(fn, { retries = 2, baseMs = 50, maxMs = 500 }
40
40
  * would lead to erroneous column transitions. If reading fails, the repository is skipped.
41
41
  */
42
42
  async function readRepo({ gitea, owner, repo, forge }) {
43
+ const provider = forge || undefined
43
44
  const [pulls, branches] = await Promise.all([
44
- gitea.listPulls({ owner, repo, state: 'all' }),
45
- gitea.listBranches({ owner, repo }),
45
+ gitea.listPulls({ owner, repo, state: 'all', provider }),
46
+ gitea.listBranches({ owner, repo, provider }),
46
47
  ])
47
48
  return { pulls, branches }
48
49
  }
package/lib/worktree.js CHANGED
@@ -28,7 +28,7 @@ export async function defaultGitRunner(args, cwd) {
28
28
  const { stdout, stderr } = await execFileAsync('git', args, {
29
29
  cwd,
30
30
  maxBuffer: 10 * 1024 * 1024,
31
- env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
31
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0', LC_ALL: 'C' },
32
32
  })
33
33
  return { stdout: stdout.trim(), stderr: stderr.trim(), code: 0, exitCode: 0 }
34
34
  } catch (err) {
@@ -137,13 +137,11 @@ export async function isGitRepo(repoDir, gitRunner = defaultGitRunner) {
137
137
  const res = await gitRunner(['rev-parse', '--is-inside-work-tree'], repoDir)
138
138
  return res.stdout?.trim() === 'true'
139
139
  } catch (err) {
140
- const msg = String(err?.stderr || err?.message || '')
140
+ const msg = String(err?.stderr || err?.message || '').toLowerCase()
141
141
  if (
142
- err?.exitCode === 128 ||
143
- err?.code === 128 ||
144
142
  msg.includes('not a git repo') ||
145
143
  msg.includes('not a git repository') ||
146
- err?.code === 'ENOENT'
144
+ (err?.code === 'ENOENT' && repoDir && !existsSync(repoDir))
147
145
  ) {
148
146
  return false
149
147
  }
@@ -394,6 +392,45 @@ export async function cleanupTaskWorktree({
394
392
  return { error: 'unfenced-worktree-path', status: 403 }
395
393
  }
396
394
 
395
+ // 1. Verify worktree cleanliness (force must not bypass dirty worktree ban #341)
396
+ const dirtyCheck = await checkWorktreeDirty({ worktreePath: current.worktree, gitRunner })
397
+ if (dirtyCheck.error) {
398
+ return { error: 'worktree-status-error', detail: dirtyCheck.error }
399
+ }
400
+ if (dirtyCheck.dirty) {
401
+ store?.addTransition?.({
402
+ taskId: current.id,
403
+ fromCol: current.column,
404
+ toCol: current.column,
405
+ source: 'session',
406
+ detail: `cleanup deferred: uncommitted files detected: ${dirtyCheck.files.slice(0, 3).join(', ')}`,
407
+ })
408
+ return { error: 'worktree-dirty', files: dirtyCheck.files }
409
+ }
410
+
411
+ // 2. Ownership fencing: ensure worktree is registered in repository before removal (#315)
412
+ let baseRepo = repoDir
413
+ if (!baseRepo && current?.repo) {
414
+ const root = typeof config?.defaultProjectRoot === 'string' && config.defaultProjectRoot.trim() !== ''
415
+ ? config.defaultProjectRoot.trim()
416
+ : (typeof cwdOf === 'function' ? cwdOf() : process.cwd())
417
+ baseRepo = resolve(join(root, current.repo))
418
+ }
419
+ if (!baseRepo) {
420
+ const rev = await gitRunner(['rev-parse', '--git-common-dir'], current.worktree)
421
+ if ((rev?.exitCode ?? rev?.code ?? 0) === 0 && rev?.stdout?.trim()) {
422
+ const cd = rev.stdout.trim()
423
+ baseRepo = cd.endsWith('/.git') ? cd.slice(0, -5) : cd
424
+ }
425
+ }
426
+ if (baseRepo) {
427
+ const registered = await listWorktrees(baseRepo, gitRunner)
428
+ const norm = resolve(current.worktree)
429
+ if (!registered.some((p) => resolve(p) === norm)) {
430
+ return { error: 'worktree-not-registered', status: 403 }
431
+ }
432
+ }
433
+
397
434
  const targetRepoDir = repoDir || current.worktree
398
435
  const result = await removeTaskWorktree({
399
436
  repoDir: targetRepoDir,
@@ -567,6 +604,15 @@ export async function mergeTaskWorktree({
567
604
  return { success: false, merged: false, error: 'main-repo-not-found' }
568
605
  }
569
606
 
607
+ // Repository ownership fence: main repository name must match task.repo (#315)
608
+ if (task.repo) {
609
+ const expectedRepo = task.repo.trim()
610
+ const baseName = basename(mainRepoDir)
611
+ if (baseName !== expectedRepo && !mainRepoDir.endsWith('/' + expectedRepo) && !mainRepoDir.endsWith(sep + expectedRepo)) {
612
+ return { success: false, merged: false, error: 'foreign-repository-mismatch' }
613
+ }
614
+ }
615
+
570
616
  const registered = await listWorktrees(mainRepoDir, gitRunner)
571
617
  const normTaskWorktree = resolve(task.worktree)
572
618
  if (!registered.some((p) => resolve(p) === normTaskWorktree)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-kanban",
3
- "version": "0.2.24",
3
+ "version": "0.2.26",
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",
@@ -18,10 +18,6 @@
18
18
  "README.ru.md",
19
19
  "LICENSE"
20
20
  ],
21
- "scripts": {
22
- "test": "node --test test/*.test.mjs",
23
- "compat": "node tools/compat.mjs"
24
- },
25
21
  "keywords": [
26
22
  "dsh",
27
23
  "dsh-plugin",
@@ -93,5 +89,9 @@
93
89
  "@deepseek-ai/dsh-tools": {
94
90
  "optional": true
95
91
  }
92
+ },
93
+ "scripts": {
94
+ "test": "node --test test/*.test.mjs",
95
+ "compat": "node tools/compat.mjs"
96
96
  }
97
- }
97
+ }