@goodandready/dsh-kanban 0.2.20 → 0.2.21
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/config.js +1 -1
- package/lib/events.js +16 -4
- package/lib/gitea.js +8 -1
- package/lib/github.js +12 -6
- package/lib/index.js +65 -8
- package/lib/launcher.js +7 -3
- package/lib/outbound.js +3 -2
- package/lib/routes.js +6 -0
- package/lib/store.js +9 -0
- package/lib/transitions.js +8 -4
- package/lib/workflow-bridge.js +17 -13
- package/lib/worktree.js +45 -2
- package/package.json +1 -1
package/lib/config.js
CHANGED
|
@@ -106,7 +106,7 @@ export const CONFIG_HINTS = {
|
|
|
106
106
|
preventIdleSleep: 'Prevent host sleep while task agent sessions are running.',
|
|
107
107
|
progressDumpEnabled: 'Enable PROGRESSDUMP state checkpoints between agent sessions.',
|
|
108
108
|
permissionGateEnabled: 'Require human confirmation before launching elevated permission tasks.',
|
|
109
|
-
sessionDefaultPermission: 'Default permission tier for task sessions (read-only, workspace-write, full).',
|
|
109
|
+
sessionDefaultPermission: 'Default permission tier for task sessions (read-only, workspace-write, danger-full-access).',
|
|
110
110
|
announceToAgent: 'Post stage change announcement messages directly into task agent session.',
|
|
111
111
|
maxConcurrentSessions: 'Maximum number of simultaneous active agent sessions on the board.',
|
|
112
112
|
workspaceClaimBoundaries: 'Enforce workspace ownership boundaries to prevent cross-workspace task execution.',
|
package/lib/events.js
CHANGED
|
@@ -28,14 +28,26 @@ export class KanbanEventHub {
|
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
handleSseRequest(req, res) {
|
|
32
|
-
|
|
31
|
+
handleSseRequest(req, res, { isTrustedRequest } = {}) {
|
|
32
|
+
if (typeof isTrustedRequest === 'function' && !isTrustedRequest(req)) {
|
|
33
|
+
res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' })
|
|
34
|
+
res.end('forbidden')
|
|
35
|
+
return () => {}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const headers = {
|
|
33
39
|
'Content-Type': 'text/event-stream',
|
|
34
40
|
'Cache-Control': 'no-cache, no-transform',
|
|
35
41
|
'Connection': 'keep-alive',
|
|
36
42
|
'X-Accel-Buffering': 'no',
|
|
37
|
-
|
|
38
|
-
|
|
43
|
+
}
|
|
44
|
+
const origin = req?.headers?.origin
|
|
45
|
+
if (origin && origin !== 'null') {
|
|
46
|
+
headers['Access-Control-Allow-Origin'] = origin
|
|
47
|
+
headers['Vary'] = 'Origin'
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
res.writeHead(200, headers)
|
|
39
51
|
|
|
40
52
|
res.write(':connected\n\n')
|
|
41
53
|
this.clients.add(res)
|
package/lib/gitea.js
CHANGED
|
@@ -36,7 +36,14 @@ export function isCredentialName(value) {
|
|
|
36
36
|
|
|
37
37
|
/** Check if value looks like a raw token secret rather than a credential reference name. */
|
|
38
38
|
export function looksLikeSecret(value) {
|
|
39
|
-
|
|
39
|
+
const str = String(value || '').trim()
|
|
40
|
+
if (str === '') return false
|
|
41
|
+
if (/^(ghp_|gho_|github_pat_|glpat-|bearer\s+|token\s+)/i.test(str)) return true
|
|
42
|
+
if (/^[0-9a-f]{40}$/i.test(str)) return true
|
|
43
|
+
if (/^eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(str)) return true
|
|
44
|
+
// Raw alphanumeric token >= 32 characters without underscores
|
|
45
|
+
if (/^[A-Za-z0-9]{32,}$/.test(str)) return true
|
|
46
|
+
return false
|
|
40
47
|
}
|
|
41
48
|
|
|
42
49
|
/**
|
package/lib/github.js
CHANGED
|
@@ -13,12 +13,18 @@ export function normalizeGithubUrl(raw) {
|
|
|
13
13
|
return match ? match[0].replace(/\/+$/, '') : DEFAULT_BASE_URL
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
export function safeIssueIndex(value) {
|
|
17
|
+
const n = typeof value === 'number' ? value : Number.parseInt(String(value ?? '').trim(), 10)
|
|
18
|
+
if (!Number.isSafeInteger(n) || n <= 0) return undefined
|
|
19
|
+
if (typeof value === 'string' && String(n) !== value.trim()) return undefined
|
|
20
|
+
return n
|
|
21
|
+
}
|
|
22
|
+
|
|
16
23
|
export function safeSegment(value) {
|
|
17
24
|
const s = String(value ?? '').trim()
|
|
18
|
-
if (!s || s.
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
return s
|
|
25
|
+
if (!s || s === '.' || s === '..') return undefined
|
|
26
|
+
if (!/^[A-Za-z0-9._-]+$/.test(s)) return undefined
|
|
27
|
+
return encodeURIComponent(s)
|
|
22
28
|
}
|
|
23
29
|
|
|
24
30
|
export function isCredentialName(value) {
|
|
@@ -170,14 +176,14 @@ export function createGitHubClient({ getConfig, resolveToken, fetchImpl = fetch,
|
|
|
170
176
|
|
|
171
177
|
/** Get single issue by number. */
|
|
172
178
|
async getIssue({ owner, repo, index }) {
|
|
173
|
-
const n =
|
|
179
|
+
const n = safeIssueIndex(index)
|
|
174
180
|
if (n === undefined) throw Object.assign(new Error('invalid issue number'), { code: 'bad-segment' })
|
|
175
181
|
return request('GET', repoPath(owner, repo, `/issues/${n}`))
|
|
176
182
|
},
|
|
177
183
|
|
|
178
184
|
/** Post comment on issue. */
|
|
179
185
|
async comment({ owner, repo, index, body }) {
|
|
180
|
-
const n =
|
|
186
|
+
const n = safeIssueIndex(index)
|
|
181
187
|
if (n === undefined) throw Object.assign(new Error('invalid issue number'), { code: 'bad-segment' })
|
|
182
188
|
return request('POST', repoPath(owner, repo, `/issues/${n}/comments`), { body })
|
|
183
189
|
},
|
package/lib/index.js
CHANGED
|
@@ -119,6 +119,18 @@ 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
|
+
|
|
122
134
|
export const Config = z.object(Object.fromEntries(
|
|
123
135
|
Object.entries(CONFIG_DEFAULTS).map(([field, fallback]) => {
|
|
124
136
|
// Node type derived from default value type.
|
|
@@ -128,7 +140,8 @@ export const Config = z.object(Object.fromEntries(
|
|
|
128
140
|
if (field === 'giteaTokenRef' || field === 'webhookSecretRef' || field === 'githubTokenRef' || field === 'githubWebhookSecretRef') {
|
|
129
141
|
node = node.role('credential-ref')
|
|
130
142
|
}
|
|
131
|
-
|
|
143
|
+
const described = node.default(fallback).description(CONFIG_HINTS[field] ?? '')
|
|
144
|
+
return [field, typeof described.volatile === 'function' ? described.volatile() : described]
|
|
132
145
|
}),
|
|
133
146
|
))
|
|
134
147
|
|
|
@@ -215,13 +228,57 @@ export function apply(ctx, config) {
|
|
|
215
228
|
}
|
|
216
229
|
}
|
|
217
230
|
|
|
231
|
+
let settingsSvc = null
|
|
232
|
+
|
|
233
|
+
const readServedConfig = () => {
|
|
234
|
+
if (typeof settingsSvc?.describe !== 'function') return null
|
|
235
|
+
try {
|
|
236
|
+
const row = settingsSvc.describe().find((r) => r?.ns === SETTINGS_NAMESPACE)
|
|
237
|
+
if (!row || row.status !== 'ready' || !row.value) return null
|
|
238
|
+
return withDefaults(row.value)
|
|
239
|
+
} catch (err) {
|
|
240
|
+
ctx.logger?.warn?.(`dsh-kanban: settings describe failed: ${err?.message || err}`)
|
|
241
|
+
return null
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
218
245
|
ctx.inject(['settings'], (sctx) => {
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
246
|
+
try {
|
|
247
|
+
settingsSvc = sctx?.settings ?? sctx?.get?.('settings') ?? null
|
|
248
|
+
const served = readServedConfig()
|
|
249
|
+
if (served) {
|
|
250
|
+
effective = served
|
|
251
|
+
rearm()
|
|
252
|
+
}
|
|
253
|
+
} catch (err) {
|
|
254
|
+
ctx.logger?.warn?.(`dsh-kanban: settings service unavailable: ${err?.message || err}`)
|
|
255
|
+
}
|
|
223
256
|
})
|
|
224
257
|
|
|
258
|
+
// Listen for config and volatile updates from host
|
|
259
|
+
if (typeof ctx?.on === 'function') {
|
|
260
|
+
const applyPatch = (updated) => {
|
|
261
|
+
if (updated && typeof updated === 'object') {
|
|
262
|
+
const incoming = updated[SETTINGS_NAMESPACE] ?? updated
|
|
263
|
+
if (incoming && typeof incoming === 'object') {
|
|
264
|
+
try {
|
|
265
|
+
effective = withDefaults({ ...effective, ...incoming })
|
|
266
|
+
rearm()
|
|
267
|
+
} catch { /* ignore invalid update */ }
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
if (typeof ctx.effect === 'function') {
|
|
272
|
+
ctx.effect(() => ctx.on('loader/volatile-update', applyPatch))
|
|
273
|
+
ctx.effect(() => ctx.on('config', applyPatch))
|
|
274
|
+
ctx.effect(() => ctx.on('settings/document-updated', applyPatch))
|
|
275
|
+
} else {
|
|
276
|
+
ctx.on('loader/volatile-update', applyPatch)
|
|
277
|
+
ctx.on('config', applyPatch)
|
|
278
|
+
ctx.on('settings/document-updated', applyPatch)
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
225
282
|
// Dedicated Gitea client resolves token via core credentials service.
|
|
226
283
|
// Secret token values are never exposed externally; only the credential reference name is stored.
|
|
227
284
|
const resolveSecret = async (name) => {
|
|
@@ -470,7 +527,7 @@ export function apply(ctx, config) {
|
|
|
470
527
|
|
|
471
528
|
// Periodic Gitea reconciliation. 0 disables it; overlapping executions are prevented.
|
|
472
529
|
// Outbound push queue to Gitea. Card moves do not block on network; failures retry in background.
|
|
473
|
-
const outbox = createOutbox({ gitea: git, store, logger: ctx.logger })
|
|
530
|
+
const outbox = createOutbox({ gitea: git, get store() { return store }, logger: ctx.logger })
|
|
474
531
|
|
|
475
532
|
const queueOutbound = (taskId, column) => {
|
|
476
533
|
if (getConfig().pushToGitea !== true) return
|
|
@@ -852,7 +909,7 @@ export function apply(ctx, config) {
|
|
|
852
909
|
kind: 'exact',
|
|
853
910
|
path: '/dsh-kanban/events',
|
|
854
911
|
handler: (req, res) => {
|
|
855
|
-
eventHub.handleSseRequest(req, res)
|
|
912
|
+
eventHub.handleSseRequest(req, res, { isTrustedRequest })
|
|
856
913
|
},
|
|
857
914
|
}), 'dsh-kanban: sse events stream')
|
|
858
915
|
|
|
@@ -1174,7 +1231,7 @@ export function apply(ctx, config) {
|
|
|
1174
1231
|
agentPreset: input?.agentPreset || task.agentPreset,
|
|
1175
1232
|
permission: input?.permission || task.permission,
|
|
1176
1233
|
permissions: ctx.get('permissionPresets'),
|
|
1177
|
-
claimAs:
|
|
1234
|
+
claimAs: await whoAmI(),
|
|
1178
1235
|
workspaceRegistry: ctx.get('workspaceRegistry'),
|
|
1179
1236
|
resume: true,
|
|
1180
1237
|
})
|
package/lib/launcher.js
CHANGED
|
@@ -289,7 +289,9 @@ export async function runTask({
|
|
|
289
289
|
if (config?.permissionGateEnabled) {
|
|
290
290
|
const perm = permission || task.permission || 'read-only'
|
|
291
291
|
const defaultPerm = config?.sessionDefaultPermission || 'read-only'
|
|
292
|
-
|
|
292
|
+
const permRank = PERMISSION_RANKS[perm] ?? Infinity
|
|
293
|
+
const defaultRank = PERMISSION_RANKS[defaultPerm] ?? 0
|
|
294
|
+
if (permRank > defaultRank) {
|
|
293
295
|
if (!task.permissionConfirmedAt || task.permissionConfirmedAt === 0) {
|
|
294
296
|
const err = new Error('Permission confirmation required')
|
|
295
297
|
err.key = 'permission-unconfirmed'
|
|
@@ -473,9 +475,11 @@ export async function runBatch({
|
|
|
473
475
|
}))
|
|
474
476
|
|
|
475
477
|
if (own !== '' && list.length === 1) {
|
|
476
|
-
send(own, 'task-start')
|
|
478
|
+
await send(own, 'task-start')
|
|
477
479
|
} else {
|
|
478
|
-
|
|
480
|
+
for (let at = 0; at < list.length; at++) {
|
|
481
|
+
await send(buildQueuedMessage(list[at], config, at + 1, list.length), 'batch-queued')
|
|
482
|
+
}
|
|
479
483
|
}
|
|
480
484
|
|
|
481
485
|
const started = []
|
package/lib/outbound.js
CHANGED
|
@@ -90,9 +90,10 @@ export function createOutbox({ gitea, store, logger, maxAttempts = 4 }) {
|
|
|
90
90
|
dropped += 1
|
|
91
91
|
logger?.warn?.(`dsh-kanban: Gitea delivery failed permanently: ${error?.message}`)
|
|
92
92
|
try {
|
|
93
|
-
const
|
|
93
|
+
const currentStore = typeof store === 'function' ? store() : store
|
|
94
|
+
const task = currentStore?.getTask?.(item.taskId)
|
|
94
95
|
if (task !== undefined) {
|
|
95
|
-
|
|
96
|
+
currentStore?.addTransition?.({
|
|
96
97
|
taskId: item.taskId,
|
|
97
98
|
fromCol: task.column,
|
|
98
99
|
toCol: task.column,
|
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({
|
package/lib/store.js
CHANGED
|
@@ -541,6 +541,15 @@ export function openStore({ dir }) {
|
|
|
541
541
|
return this.updateTask(id, { comments })
|
|
542
542
|
},
|
|
543
543
|
|
|
544
|
+
addTaskComment(id, comment) {
|
|
545
|
+
if (!comment || typeof comment !== 'object') return undefined
|
|
546
|
+
return this.addComment(id, {
|
|
547
|
+
author: comment.author,
|
|
548
|
+
role: comment.role,
|
|
549
|
+
text: comment.text || comment.body || '',
|
|
550
|
+
})
|
|
551
|
+
},
|
|
552
|
+
|
|
544
553
|
updateChecklist(id, items) {
|
|
545
554
|
const task = rowToTask(q.taskById.get(id))
|
|
546
555
|
if (!task) return undefined
|
package/lib/transitions.js
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
function escapeRegex(str) {
|
|
2
|
+
return String(str).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
3
|
+
}
|
|
4
|
+
|
|
1
5
|
// Card auto-movement: derive column from observations and source priority.
|
|
2
6
|
// Pure module without network, storage, or harness dependencies.
|
|
3
7
|
// Observations arrive as data, verdicts return as values.
|
|
@@ -85,19 +89,19 @@ export function branchOfTask(task, branches) {
|
|
|
85
89
|
|
|
86
90
|
// Agent creates branch matching workflow conventions with issue number.
|
|
87
91
|
// Look up by issue number rather than fixed string template.
|
|
88
|
-
if (
|
|
89
|
-
const marker =
|
|
92
|
+
if (!Number.isSafeInteger(task?.issueNumber) || task.issueNumber <= 0) return undefined
|
|
93
|
+
const marker = escapeRegex(task.issueNumber)
|
|
90
94
|
return names.find((n) => new RegExp(`(^|[^0-9])${marker}([^0-9]|$)`).test(n))
|
|
91
95
|
}
|
|
92
96
|
|
|
93
97
|
/** Pull requests related to task: matched by branch or issue reference. */
|
|
94
98
|
export function pullsOfTask(task, pulls, branch) {
|
|
95
|
-
const number =
|
|
99
|
+
const number = Number.isSafeInteger(task?.issueNumber) && task.issueNumber > 0 ? task.issueNumber : undefined
|
|
96
100
|
return (pulls ?? []).filter((p) => {
|
|
97
101
|
if (branch !== undefined && p?.head?.ref === branch) return true
|
|
98
102
|
if (number === undefined) return false
|
|
99
103
|
const haystack = `${p?.title ?? ''} ${p?.body ?? ''}`
|
|
100
|
-
return new RegExp(`#${number}([^0-9]|$)`).test(haystack)
|
|
104
|
+
return new RegExp(`#${escapeRegex(number)}([^0-9]|$)`).test(haystack)
|
|
101
105
|
})
|
|
102
106
|
}
|
|
103
107
|
|
package/lib/workflow-bridge.js
CHANGED
|
@@ -120,18 +120,20 @@ export function resolvePreset({ role, availablePresets = [], defaultPreset = und
|
|
|
120
120
|
* @returns {string}
|
|
121
121
|
*/
|
|
122
122
|
export function buildWorkflowScript({ task, role }) {
|
|
123
|
-
const
|
|
124
|
-
const
|
|
125
|
-
const
|
|
123
|
+
const rawRole = String(role || 'backend')
|
|
124
|
+
const rawTaskId = String(task?.id || 'unknown')
|
|
125
|
+
const validIssue = Number.isSafeInteger(task?.issueNumber) && task.issueNumber > 0 ? task.issueNumber : undefined
|
|
126
|
+
const cardLabel = validIssue !== undefined ? `#${validIssue}` : rawTaskId
|
|
127
|
+
const logMsg = `Starting phase ${rawRole} for card ${cardLabel}`
|
|
126
128
|
|
|
127
129
|
return [
|
|
128
|
-
`phase(
|
|
129
|
-
`log(
|
|
130
|
+
`phase(${JSON.stringify(rawRole)});`,
|
|
131
|
+
`log(${JSON.stringify(logMsg)});`,
|
|
130
132
|
`const prompt = args.prompt;`,
|
|
131
|
-
`const result = await agent(prompt, { label:
|
|
133
|
+
`const result = await agent(prompt, { label: ${JSON.stringify(rawRole)}, phase: ${JSON.stringify(rawRole)} });`,
|
|
132
134
|
`return {`,
|
|
133
|
-
` role:
|
|
134
|
-
` taskId:
|
|
135
|
+
` role: ${JSON.stringify(rawRole)},`,
|
|
136
|
+
` taskId: ${JSON.stringify(rawTaskId)},`,
|
|
135
137
|
` summary: typeof result === 'string' ? result : (result ? JSON.stringify(result) : '')`,
|
|
136
138
|
`};`,
|
|
137
139
|
].join('\n')
|
|
@@ -469,11 +471,13 @@ export class WorkflowBridge {
|
|
|
469
471
|
}
|
|
470
472
|
|
|
471
473
|
// Add comment to card
|
|
472
|
-
if (
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
})
|
|
474
|
+
if (summary) {
|
|
475
|
+
const text = `🚀 **Workflow completed (${role}):**\n\n${summary}`
|
|
476
|
+
if (typeof this.store?.addComment === 'function') {
|
|
477
|
+
this.store.addComment(taskId, { author: 'workflow', role: 'workflow', text })
|
|
478
|
+
} else if (typeof this.store?.addTaskComment === 'function') {
|
|
479
|
+
this.store.addTaskComment(taskId, { author: 'workflow', role: 'workflow', text, body: text })
|
|
480
|
+
}
|
|
477
481
|
}
|
|
478
482
|
} else {
|
|
479
483
|
this.store.addTransition({
|
package/lib/worktree.js
CHANGED
|
@@ -87,6 +87,22 @@ export function resolveWorktreeRoot(config, env = process.env, cwdOf = () => pro
|
|
|
87
87
|
/**
|
|
88
88
|
* Resolve canonical worktree path for task with path traversal protection.
|
|
89
89
|
*/
|
|
90
|
+
export function isSafeBranchName(name) {
|
|
91
|
+
if (typeof name !== 'string') return false
|
|
92
|
+
const trimmed = name.trim()
|
|
93
|
+
if (trimmed === '' || trimmed.startsWith('-') || trimmed.startsWith('/') || trimmed.endsWith('/')) return false
|
|
94
|
+
if (trimmed.includes('..') || trimmed.includes('~') || trimmed.includes('^') || trimmed.includes(':') || trimmed.includes('?') || trimmed.includes('*') || trimmed.includes('[') || trimmed.includes('\\') || trimmed.includes('@{') || trimmed.endsWith('.lock')) return false
|
|
95
|
+
return /^[A-Za-z0-9_./-]+$/.test(trimmed)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function isPathFenced(targetPath, rootPath) {
|
|
99
|
+
if (!targetPath || !rootPath) return false
|
|
100
|
+
const resolvedTarget = resolve(targetPath)
|
|
101
|
+
const resolvedRoot = resolve(rootPath)
|
|
102
|
+
const fence = resolvedRoot.endsWith(sep) || resolvedRoot.endsWith('/') ? resolvedRoot : resolvedRoot + sep
|
|
103
|
+
return resolvedTarget === resolvedRoot || resolvedTarget.startsWith(fence)
|
|
104
|
+
}
|
|
105
|
+
|
|
90
106
|
export function resolveTaskWorktreePath({ task, config, env, cwdOf }) {
|
|
91
107
|
const root = resolveWorktreeRoot(config, env, cwdOf)
|
|
92
108
|
const repoKey = safeRepoKey(task)
|
|
@@ -262,11 +278,16 @@ export async function removeTaskWorktree({
|
|
|
262
278
|
force = false,
|
|
263
279
|
deleteBranch = true,
|
|
264
280
|
gitRunner = defaultGitRunner,
|
|
281
|
+
rootPath,
|
|
265
282
|
}) {
|
|
266
283
|
if (!worktreePath || !existsSync(worktreePath)) {
|
|
267
284
|
return { removed: false, reason: 'not-found' }
|
|
268
285
|
}
|
|
269
286
|
|
|
287
|
+
if (rootPath && !isPathFenced(worktreePath, rootPath)) {
|
|
288
|
+
return { removed: false, error: 'unfenced-worktree-path' }
|
|
289
|
+
}
|
|
290
|
+
|
|
270
291
|
const dirtyCheck = await checkWorktreeDirty({ worktreePath, gitRunner })
|
|
271
292
|
if (dirtyCheck.dirty && !force) {
|
|
272
293
|
return {
|
|
@@ -333,11 +354,26 @@ export async function cleanupTaskWorktree({
|
|
|
333
354
|
force = false,
|
|
334
355
|
deleteBranch = true,
|
|
335
356
|
gitRunner = defaultGitRunner,
|
|
357
|
+
config,
|
|
358
|
+
env,
|
|
359
|
+
cwdOf,
|
|
336
360
|
}) {
|
|
337
361
|
const current = typeof task === 'object' && task !== null ? task : store?.getTask?.(task)
|
|
338
362
|
if (!current) return { error: 'task-not-found', status: 404 }
|
|
339
363
|
if (!current.worktree) return { noop: true, removed: false }
|
|
340
364
|
|
|
365
|
+
const root = resolveWorktreeRoot(config, env, cwdOf)
|
|
366
|
+
const candidateRoots = [
|
|
367
|
+
root,
|
|
368
|
+
repoDir,
|
|
369
|
+
typeof cwdOf === 'function' ? cwdOf() : process.cwd(),
|
|
370
|
+
process.cwd(),
|
|
371
|
+
].filter(Boolean)
|
|
372
|
+
const isFenced = candidateRoots.some((c) => isPathFenced(current.worktree, c))
|
|
373
|
+
if (!isFenced) {
|
|
374
|
+
return { error: 'unfenced-worktree-path', status: 403 }
|
|
375
|
+
}
|
|
376
|
+
|
|
341
377
|
const targetRepoDir = repoDir || current.worktree
|
|
342
378
|
const result = await removeTaskWorktree({
|
|
343
379
|
repoDir: targetRepoDir,
|
|
@@ -479,6 +515,10 @@ export async function mergeTaskWorktree({
|
|
|
479
515
|
return { success: false, merged: false, error: 'no-worktree-or-branch' }
|
|
480
516
|
}
|
|
481
517
|
|
|
518
|
+
if (targetBranch && !isSafeBranchName(targetBranch)) {
|
|
519
|
+
return { success: false, merged: false, error: 'invalid-target-branch' }
|
|
520
|
+
}
|
|
521
|
+
|
|
482
522
|
// 1. Verify worktree cleanliness
|
|
483
523
|
const dirtyCheck = await checkWorktreeDirty({ worktreePath: task.worktree, gitRunner })
|
|
484
524
|
if (dirtyCheck.dirty) {
|
|
@@ -499,15 +539,18 @@ export async function mergeTaskWorktree({
|
|
|
499
539
|
|
|
500
540
|
// 3. Resolve target merge branch
|
|
501
541
|
let target = targetBranch
|
|
542
|
+
if (target && !isSafeBranchName(target)) {
|
|
543
|
+
return { success: false, merged: false, error: 'invalid-target-branch' }
|
|
544
|
+
}
|
|
502
545
|
if (!target) {
|
|
503
546
|
target = await determineBaseRef(mainRepoDir, gitRunner)
|
|
504
547
|
}
|
|
505
|
-
if (!target) target = 'main'
|
|
548
|
+
if (!target || !isSafeBranchName(target)) target = 'main'
|
|
506
549
|
|
|
507
550
|
// 4. Switch or verify target branch in main repository
|
|
508
551
|
const currentBranch = await gitRunner(['branch', '--show-current'], mainRepoDir)
|
|
509
552
|
if (currentBranch.stdout.trim() !== target) {
|
|
510
|
-
const checkoutRes = await gitRunner(['checkout', target], mainRepoDir)
|
|
553
|
+
const checkoutRes = await gitRunner(['checkout', '--', target], mainRepoDir)
|
|
511
554
|
if (checkoutRes.exitCode !== 0) {
|
|
512
555
|
return { success: false, merged: false, error: 'checkout-target-failed', detail: checkoutRes.stderr }
|
|
513
556
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-kanban",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.21",
|
|
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",
|