claude-code-kanban 7.6.1 → 7.7.0
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/README.md +2 -1
- package/cli.js +412 -62
- package/hub-app.json +1 -1
- package/kanbot/.claude-plugin/plugin.json +5 -0
- package/kanbot/agents/kanbot.md +34 -0
- package/lib/http-error.js +2 -1
- package/lib/kanbot.js +86 -0
- package/lib/owner-routing.js +53 -0
- package/lib/preview-action.js +55 -0
- package/lib/session-events.js +5 -0
- package/lib/session-pins.js +99 -0
- package/lib/stamped-json-file.js +49 -0
- package/lib/terminal.js +27 -13
- package/lib/user-groups.js +364 -0
- package/package.json +2 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/claude-code-kanban/skills/kanban/SKILL.md +4 -2
- package/plugin/plugins/claude-code-kanban/skills/show/SKILL.md +5 -1
- package/public/app.js +1196 -295
- package/public/index.html +19 -15
- package/public/style.css +727 -125
- package/public/terminal-frame.js +12 -0
- package/server.js +273 -117
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
// User groups: named groups that hold projects and sessions, and may nest under another group.
|
|
2
|
+
// The CLI and plugins change them one operation at a time; the board writes its copy whole with
|
|
3
|
+
// `replace`. `groups` is one flat list; its order is the order of siblings. A member is in at
|
|
4
|
+
// most one group.
|
|
5
|
+
|
|
6
|
+
const { randomUUID } = require('node:crypto');
|
|
7
|
+
const { httpError } = require('./http-error');
|
|
8
|
+
const { oneLine } = require('./one-line');
|
|
9
|
+
|
|
10
|
+
const MAX_GROUPS = 200;
|
|
11
|
+
const MAX_MEMBERS = 500;
|
|
12
|
+
const MAX_RELEASED = 500;
|
|
13
|
+
const MAX_DEPTH = 8;
|
|
14
|
+
const MAX_NAME = 120;
|
|
15
|
+
const MAX_REF = 4096;
|
|
16
|
+
const ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
17
|
+
const TYPES = new Set(['project', 'session']);
|
|
18
|
+
// `group list` shows a dispatch group as `dispatch:<name>`; a key with it names the user group <name>,
|
|
19
|
+
// which takes that dispatch group's sessions in.
|
|
20
|
+
const DISPATCH_PREFIX = 'dispatch:';
|
|
21
|
+
const dispatchName = (key) => (typeof key === 'string' && key.startsWith(DISPATCH_PREFIX) ? key.slice(DISPATCH_PREFIX.length) : null);
|
|
22
|
+
|
|
23
|
+
const fail = (status, message) => {
|
|
24
|
+
throw httpError(status, message);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const isRef = (s) => typeof s === 'string' && s.length > 0 && s.length <= MAX_REF;
|
|
28
|
+
const cleanName = (name) => (typeof name === 'string' && oneLine(name, MAX_NAME)) || null;
|
|
29
|
+
const memberKey = (m) => `${m.type}\0${m.ref}`;
|
|
30
|
+
|
|
31
|
+
function cleanMember(m) {
|
|
32
|
+
if (!m || !TYPES.has(m.type) || !isRef(m.ref)) return null;
|
|
33
|
+
const out = { type: m.type, ref: m.ref };
|
|
34
|
+
if (m.type === 'session' && isRef(m.under)) out.under = m.under;
|
|
35
|
+
else if (m.type === 'session' && m.loose === true) out.loose = true;
|
|
36
|
+
if (m.type === 'session' && isRef(m.parent) && m.parent !== m.ref) out.parent = m.parent;
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const sameMember = (a, b) => a.type === b.type && a.ref === b.ref;
|
|
41
|
+
|
|
42
|
+
const groupHolding = (groups, member) => groups.find((g) => g.members.some((m) => sameMember(m, member)));
|
|
43
|
+
const groupsNamed = (groups, name) => groups.filter((g) => g.name.toLowerCase() === name.toLowerCase());
|
|
44
|
+
|
|
45
|
+
// Where the board shows a session (sgGroupForSession in public/app.js): `held` is the user group
|
|
46
|
+
// that holds it or its project. Else, unless released, `named` is a user group named like its
|
|
47
|
+
// dispatch group, which takes it in, and with none `dispatch` is that dispatch group's name.
|
|
48
|
+
function placeSession(groups, released, { ref, project, dispatchGroup }) {
|
|
49
|
+
const held = groupHolding(groups, { type: 'session', ref }) || (project && groupHolding(groups, { type: 'project', ref: project }));
|
|
50
|
+
if (held) return { held, named: null, dispatch: null };
|
|
51
|
+
const dispatch = dispatchGroup && !released.includes(ref) ? dispatchGroup : null;
|
|
52
|
+
const named = (dispatch && groupsNamed(groups, dispatch)[0]) || null;
|
|
53
|
+
return { held: null, named, dispatch: named ? null : dispatch };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function wouldCycle(list, id, parent) {
|
|
57
|
+
let depth = 0;
|
|
58
|
+
for (let p = parent; p; p = list.find((g) => g.id === p)?.parent ?? null) {
|
|
59
|
+
if (p === id || ++depth >= MAX_DEPTH) return true;
|
|
60
|
+
}
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// `taken` holds member keys already placed; a member found again is dropped, so the first group
|
|
65
|
+
// that lists it keeps it.
|
|
66
|
+
function readGroups(list, taken = new Set()) {
|
|
67
|
+
const out = [];
|
|
68
|
+
const seen = new Set();
|
|
69
|
+
for (const g of Array.isArray(list) ? list : []) {
|
|
70
|
+
if (!g || !ID_RE.test(g.id) || seen.has(g.id) || out.length >= MAX_GROUPS) continue;
|
|
71
|
+
seen.add(g.id);
|
|
72
|
+
const members = [];
|
|
73
|
+
for (const m of Array.isArray(g.members) ? g.members : []) {
|
|
74
|
+
const clean = cleanMember(m);
|
|
75
|
+
if (!clean || members.length >= MAX_MEMBERS || taken.has(memberKey(clean))) continue;
|
|
76
|
+
taken.add(memberKey(clean));
|
|
77
|
+
members.push(clean);
|
|
78
|
+
}
|
|
79
|
+
out.push({ id: g.id, name: cleanName(g.name) || 'Group', parent: typeof g.parent === 'string' ? g.parent : null, members });
|
|
80
|
+
}
|
|
81
|
+
for (const g of out) {
|
|
82
|
+
if (g.parent && (!seen.has(g.parent) || g.parent === g.id)) g.parent = null;
|
|
83
|
+
}
|
|
84
|
+
for (const g of out) if (wouldCycle(out, g.id, g.parent)) g.parent = null;
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const readReleased = (list) => [...new Set((Array.isArray(list) ? list : []).filter(isRef))].slice(-MAX_RELEASED);
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* @param {object} o
|
|
92
|
+
* @param {() => object|null|undefined} o.load `undefined` means keep what is in memory: the file has
|
|
93
|
+
* not changed since the last load, or cannot be read
|
|
94
|
+
* @param {(data: object) => void} o.save throws when the groups must not or cannot be written
|
|
95
|
+
*/
|
|
96
|
+
function createUserGroupStore({ load, save, now = () => new Date().toISOString(), newId = randomUUID }) {
|
|
97
|
+
let groups = [];
|
|
98
|
+
let released = [];
|
|
99
|
+
let rev = 0;
|
|
100
|
+
let migratedAt = null;
|
|
101
|
+
// The last state read or saved. The changes edit `groups` in place, so a refused save puts it back.
|
|
102
|
+
let lastGood = {};
|
|
103
|
+
|
|
104
|
+
function apply(data) {
|
|
105
|
+
rev = Number.isSafeInteger(data.rev) && data.rev >= 0 ? data.rev : 0;
|
|
106
|
+
migratedAt = typeof data.groupsMigratedAt === 'string' ? data.groupsMigratedAt : null;
|
|
107
|
+
groups = readGroups(data.groups);
|
|
108
|
+
released = readReleased(data.released);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Another board on the same config dir writes the same file. Returns whether the rev moved.
|
|
112
|
+
function reload() {
|
|
113
|
+
const saved = load();
|
|
114
|
+
if (saved === undefined) return false;
|
|
115
|
+
const before = rev;
|
|
116
|
+
lastGood = saved && typeof saved === 'object' ? saved : {};
|
|
117
|
+
apply(lastGood);
|
|
118
|
+
return rev !== before;
|
|
119
|
+
}
|
|
120
|
+
reload();
|
|
121
|
+
|
|
122
|
+
const byId = (id) => groups.find((g) => g.id === id) || null;
|
|
123
|
+
const mustGet = (id) => byId(id) || fail(404, `no group ${id}`);
|
|
124
|
+
const groupOf = (member) => groupHolding(groups, member);
|
|
125
|
+
const byName = (name) => groupsNamed(groups, name);
|
|
126
|
+
|
|
127
|
+
const state = () => ({ version: 2, rev, groupsMigratedAt: migratedAt, groups, released });
|
|
128
|
+
|
|
129
|
+
function commit() {
|
|
130
|
+
rev++;
|
|
131
|
+
const data = structuredClone(state());
|
|
132
|
+
try {
|
|
133
|
+
save(data);
|
|
134
|
+
} catch (e) {
|
|
135
|
+
apply(lastGood);
|
|
136
|
+
throw e;
|
|
137
|
+
}
|
|
138
|
+
lastGood = data;
|
|
139
|
+
return state();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function checkParent(id, parent) {
|
|
143
|
+
if (parent == null) return null;
|
|
144
|
+
if (typeof parent !== 'string' || !byId(parent)) fail(400, `no parent group ${parent}`);
|
|
145
|
+
if (wouldCycle(groups, id, parent)) fail(400, 'parent would make a cycle or nest too deep');
|
|
146
|
+
return parent;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function moveBefore(list, item, before, same) {
|
|
150
|
+
const from = list.indexOf(item);
|
|
151
|
+
if (from >= 0) list.splice(from, 1);
|
|
152
|
+
const to = before == null ? -1 : list.findIndex((x) => same(x, before));
|
|
153
|
+
if (to >= 0) list.splice(to, 0, item);
|
|
154
|
+
else list.push(item);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function detach(keys) {
|
|
158
|
+
for (const g of groups) g.members = g.members.filter((m) => !keys.has(memberKey(m)));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function addGroup({ id, name, parent = null, before = null, members = [] }) {
|
|
162
|
+
if (groups.length >= MAX_GROUPS) fail(422, `at most ${MAX_GROUPS} groups`);
|
|
163
|
+
if (id != null && (!ID_RE.test(id) || byId(id))) fail(400, 'id must be unique, 1-64 of [A-Za-z0-9_-]');
|
|
164
|
+
const gid = id ?? newGroupId();
|
|
165
|
+
const clean = readGroups([{ id: gid, members }])[0].members;
|
|
166
|
+
const group = { id: gid, name: cleanName(name) || `Group ${groups.length + 1}`, parent: checkParent(gid, parent), members: clean };
|
|
167
|
+
detach(new Set(clean.map(memberKey)));
|
|
168
|
+
groups.push(group);
|
|
169
|
+
moveBefore(groups, group, before, (g, id) => g.id === id);
|
|
170
|
+
return group;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function create(opts = {}) {
|
|
174
|
+
const group = addGroup(opts);
|
|
175
|
+
return { group: group.id, ...commit() };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const pathOf = (group) => {
|
|
179
|
+
const names = [];
|
|
180
|
+
for (let g = group; g; g = byId(g.parent)) names.unshift(g.name);
|
|
181
|
+
return names.join('/');
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
// A path names a top-level group, then a child at each step. `make` adds the missing steps.
|
|
185
|
+
function findPath(path, make, hint) {
|
|
186
|
+
const steps = path.split('/').map(cleanName);
|
|
187
|
+
if (steps.some((s) => !s)) fail(400, `group path "${path}" has an empty step`);
|
|
188
|
+
if (steps.length > MAX_DEPTH) fail(400, `a group path has at most ${MAX_DEPTH} steps`);
|
|
189
|
+
let parent = null;
|
|
190
|
+
let created = false;
|
|
191
|
+
for (const step of steps) {
|
|
192
|
+
const named = byName(step).filter((g) => g.parent === (parent?.id ?? null));
|
|
193
|
+
if (named.length > 1) fail(400, `${named.length} groups are named "${pathOf(named[0])}"; pass one id: ${named.map((g) => g.id).join(', ')}`);
|
|
194
|
+
if (named.length) parent = named[0];
|
|
195
|
+
else if (make) [parent, created] = [addGroup({ name: step, parent: parent?.id ?? null }), true];
|
|
196
|
+
else return fail(404, `no group "${parent ? `${pathOf(parent)}/` : ''}${step}"; ${hint}`);
|
|
197
|
+
}
|
|
198
|
+
return { group: parent, created };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// The CLI names a group the way the user sees it: by id, by name ignoring case, or by path.
|
|
202
|
+
function findGroup(key, make, hint = 'pass --create to make it, or see `group list`') {
|
|
203
|
+
if (!isRef(key) || !key.trim()) fail(400, 'group must be a group id, name or path');
|
|
204
|
+
const hit = byId(key);
|
|
205
|
+
if (hit) return { group: hit, created: false };
|
|
206
|
+
const name = cleanName(key);
|
|
207
|
+
const named = byName(name);
|
|
208
|
+
if (!named.length && key.includes('/')) return findPath(key, make, hint);
|
|
209
|
+
if (named.length > 1) fail(400, `${named.length} groups are named "${name}"; pass one id: ${named.map((g) => g.id).join(', ')}`);
|
|
210
|
+
if (named.length) return { group: named[0], created: false };
|
|
211
|
+
if (make) return { group: addGroup({ name }), created: true };
|
|
212
|
+
const known = groups.map((g) => g.name).join(', ');
|
|
213
|
+
return fail(404, `no group "${name}"${known ? ` (groups: ${known})` : ''}; ${hint}`);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// The group the board shows `peer` in: its own placement, its project's, or a user group named
|
|
217
|
+
// like its dispatch group. A new group holds `peer` too.
|
|
218
|
+
function peerGroup(peer, make) {
|
|
219
|
+
const { ref, name } = peer;
|
|
220
|
+
const { held, named, dispatch } = placeSession(groups, released, peer);
|
|
221
|
+
const hit = held || named;
|
|
222
|
+
if (hit) return { group: hit, created: false };
|
|
223
|
+
if (!make) return fail(404, `session ${ref.slice(0, 8)} is in no group; pass --create to make one`);
|
|
224
|
+
return { group: addGroup({ name: dispatch || name, members: [{ type: 'session', ref }] }), created: true };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// A session placed with no `under` or `loose` shows in its project's block when the group holds
|
|
228
|
+
// its project, else on the group's own level, as the board's context menu places it.
|
|
229
|
+
// `peer` ({ref, project, dispatchGroup, name}) puts the session in the group of that session.
|
|
230
|
+
// `parent` names the session the board shows it under; null clears it.
|
|
231
|
+
function groupSession(ref, { group, peer, parent, create: make = false } = {}) {
|
|
232
|
+
// Checked before findGroup, which may add the group.
|
|
233
|
+
if (!isRef(ref)) fail(400, 'session id is required');
|
|
234
|
+
if (parent != null && (!isRef(parent) || parent === ref)) fail(400, 'parent must be a different session');
|
|
235
|
+
const member = { type: 'session', ref, parent };
|
|
236
|
+
const left = groupOf(member)?.name ?? null;
|
|
237
|
+
if (peer) {
|
|
238
|
+
if (!isRef(peer.ref) || peer.ref === ref) fail(400, 'the other session must be a different session');
|
|
239
|
+
} else if (group == null) {
|
|
240
|
+
if (left === null && released.includes(ref)) return { group: null, left, ...state() };
|
|
241
|
+
return { group: null, left, ...ungroup({ ...member, release: true }) };
|
|
242
|
+
}
|
|
243
|
+
const dispatched = dispatchName(group);
|
|
244
|
+
const { group: hit, created } = peer ? peerGroup(peer, make) : findGroup(dispatched ?? group, make || dispatched !== null);
|
|
245
|
+
return { ...groupRef(hit), created, left, ...place(hit.id, member) };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function update(id, { name, parent, before } = {}) {
|
|
249
|
+
const group = mustGet(id);
|
|
250
|
+
const nextName = name === undefined ? group.name : cleanName(name) || fail(400, 'name must be a non-empty string');
|
|
251
|
+
const nextParent = parent === undefined ? group.parent : checkParent(id, parent);
|
|
252
|
+
if (before != null && !byId(before)) fail(400, `no group ${before}`);
|
|
253
|
+
group.name = nextName;
|
|
254
|
+
group.parent = nextParent;
|
|
255
|
+
if (before !== undefined) moveBefore(groups, group, before, (g, id) => g.id === id);
|
|
256
|
+
return commit();
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function remove(id) {
|
|
260
|
+
const group = mustGet(id);
|
|
261
|
+
for (const g of groups) if (g.parent === id) g.parent = group.parent;
|
|
262
|
+
groups = groups.filter((g) => g !== group);
|
|
263
|
+
return commit();
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const describe = (g) => ({ id: g.id, name: g.name, path: pathOf(g) });
|
|
267
|
+
const groupRef = (g) => ({ group: g.id, name: g.name, path: pathOf(g) });
|
|
268
|
+
|
|
269
|
+
function userGroupByKey(key) {
|
|
270
|
+
if (dispatchName(key) !== null) fail(400, 'a dispatch group goes when its sessions end; `session ungroup` takes a session out');
|
|
271
|
+
return findGroup(key, false, 'see `group list`').group;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function createPath(path) {
|
|
275
|
+
const { group, created } = findGroup(dispatchName(path) ?? path, true);
|
|
276
|
+
if (!created) fail(400, `group "${pathOf(group)}" exists (${group.id})`);
|
|
277
|
+
return { ...groupRef(group), rev: commit().rev };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function rename(key, name) {
|
|
281
|
+
const group = userGroupByKey(key);
|
|
282
|
+
const { rev: saved } = update(group.id, { name });
|
|
283
|
+
return { ...groupRef(group), rev: saved };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function removeByKey(key) {
|
|
287
|
+
const group = userGroupByKey(key);
|
|
288
|
+
return { removed: describe(group), ungrouped: group.members.length, rev: remove(group.id).rev };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// A parent that holds only empty groups goes with them.
|
|
292
|
+
function prune({ dryRun = false, isLive }) {
|
|
293
|
+
const empty = (g) => !g.members.some(isLive) && groups.every((c) => c.parent !== g.id || empty(c));
|
|
294
|
+
const doomed = groups.filter(empty);
|
|
295
|
+
const removed = doomed.map(describe);
|
|
296
|
+
if (dryRun || !doomed.length) return { removed, dryRun, rev };
|
|
297
|
+
groups = groups.filter((g) => !doomed.includes(g));
|
|
298
|
+
return { removed, dryRun, rev: commit().rev };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// `supersede` names session members to drop with the move: the sessions of a project being
|
|
302
|
+
// moved, which only the caller can list. With no `parent`, a move inside the group keeps it.
|
|
303
|
+
function place(id, { type, ref, under, loose, parent, before = null, supersede = [] } = {}) {
|
|
304
|
+
const group = mustGet(id);
|
|
305
|
+
const kept = parent === undefined ? group.members.find((m) => sameMember(m, { type, ref }))?.parent : parent;
|
|
306
|
+
const member = cleanMember({ type, ref, under, loose, parent: kept }) || fail(400, 'type must be project or session, with a ref');
|
|
307
|
+
if (group.members.filter((m) => !sameMember(m, member)).length >= MAX_MEMBERS) fail(422, `at most ${MAX_MEMBERS} members`);
|
|
308
|
+
const keys = new Set([memberKey(member)]);
|
|
309
|
+
for (const sid of Array.isArray(supersede) ? supersede : []) if (isRef(sid)) keys.add(memberKey({ type: 'session', ref: sid }));
|
|
310
|
+
detach(keys);
|
|
311
|
+
moveBefore(group.members, member, before, sameMember);
|
|
312
|
+
return commit();
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function ungroup({ type, ref, release = false } = {}) {
|
|
316
|
+
const member = cleanMember({ type, ref }) || fail(400, 'type must be project or session, with a ref');
|
|
317
|
+
detach(new Set([memberKey(member)]));
|
|
318
|
+
if (release && type === 'session') addReleased([ref]);
|
|
319
|
+
return commit();
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function addReleased(ids) {
|
|
323
|
+
released = readReleased([...released.filter((x) => !ids.includes(x)), ...ids]);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function release(ids) {
|
|
327
|
+
if (!Array.isArray(ids)) fail(400, 'ids must be an array');
|
|
328
|
+
addReleased(ids);
|
|
329
|
+
return commit();
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// The board edits its own copy and writes it whole; `rev` must be the one it last read.
|
|
333
|
+
function replace({ rev: base, groups: list, released: rel } = {}) {
|
|
334
|
+
if (base !== rev) fail(409, 'groups changed since rev; read them again');
|
|
335
|
+
groups = readGroups(list);
|
|
336
|
+
released = readReleased(rel);
|
|
337
|
+
return commit();
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// The board's groups from before the server kept them. Only the first import is taken; a later
|
|
341
|
+
// one changes nothing. A member the server already holds stays where it is.
|
|
342
|
+
function importLocal({ groups: list, released: rel } = {}) {
|
|
343
|
+
if (migratedAt) return { imported: false, ...state() };
|
|
344
|
+
const taken = new Set(groups.flatMap((g) => g.members.map(memberKey)));
|
|
345
|
+
for (const g of readGroups(list, taken)) {
|
|
346
|
+
if (groups.length >= MAX_GROUPS) break;
|
|
347
|
+
groups.push({ ...g, id: byId(g.id) ? newGroupId() : g.id, parent: null });
|
|
348
|
+
}
|
|
349
|
+
addReleased(readReleased(rel));
|
|
350
|
+
migratedAt = now();
|
|
351
|
+
return { imported: true, ...commit() };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function newGroupId() {
|
|
355
|
+
let id;
|
|
356
|
+
do id = newId();
|
|
357
|
+
while (byId(id));
|
|
358
|
+
return id;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return { state, reload, create, update, remove, place, ungroup, release, replace, importLocal, groupSession, createPath, rename, removeByKey, prune };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
module.exports = { createUserGroupStore, placeSession, DISPATCH_PREFIX };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-kanban",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.7.0",
|
|
4
4
|
"description": "A web-based Kanban board for viewing Claude Code tasks with agent teams support",
|
|
5
5
|
"main": "server.js",
|
|
6
6
|
"type": "commonjs",
|
|
@@ -55,6 +55,7 @@
|
|
|
55
55
|
"cli.js",
|
|
56
56
|
"install.js",
|
|
57
57
|
"plugin/**/*",
|
|
58
|
+
"kanbot/**/*",
|
|
58
59
|
"lib/**/*",
|
|
59
60
|
"public/**/*",
|
|
60
61
|
"skill-guides/**/*"
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-kanban",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.7.4",
|
|
4
4
|
"description": "claude-code-kanban dashboard integration: agent activity tracking, context and cost tracking, board moves and reviews delivered to the session, and skills to drive the board from a session"
|
|
5
5
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: kanban
|
|
3
3
|
description: Kanban board CLI — show the user a file or page, link a doc or PR, add a pane, open or pin a session, find a session's transcript, list tasks.
|
|
4
|
-
argument-hint: '[doc|pane|session|task|project|dispatch] <subcommand> [target]'
|
|
4
|
+
argument-hint: '[doc|pane|session|task|project|dispatch|terminal] <subcommand> [target]'
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
# Kanban Skill
|
|
@@ -16,10 +16,12 @@ This session id is `${CLAUDE_SESSION_ID}`. `--session` defaults to it through `$
|
|
|
16
16
|
|---|---|
|
|
17
17
|
| `doc` | `link`, `unlink` a file or URL to the session · `list` its links · `preview` a file in a modal |
|
|
18
18
|
| `pane` | `add` a URL or a local file as a background tab · `rm` · `list` |
|
|
19
|
-
| `session` | `list` · `search <text>` · `open <id>` focuses it · `view <id>` stats and transcript path · `plan <id>` · `agents <id>` · `pin <id>` |
|
|
19
|
+
| `session` | `list` · `search <text>` · `open <id>` focuses it · `view <id>` stats and transcript path · `plan <id>` · `agents <id>` · `pin <id>` · `group <id> <group>`, `ungroup <id>` in the sidebar |
|
|
20
|
+
| `group` | `list` the sidebar groups, by name and id · `create <name>` · `rename <group> <name>` · `rm <group>` · `prune` the empty ones |
|
|
20
21
|
| `task` | `list` for a session, a project or the whole board |
|
|
21
22
|
| `project` | `list` |
|
|
22
23
|
| `dispatch` | `start`, `list`, `end` — run `/claude-code-kanban:dispatch` instead |
|
|
24
|
+
| `terminal` | `list` every terminal in cck, read-only |
|
|
23
25
|
|
|
24
26
|
## Run a command
|
|
25
27
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: show
|
|
3
|
-
description: Show card guide for the cck overlay.
|
|
3
|
+
description: Show card guide for the cck overlay. Cards can be a table, chart, diagram, mermaid, mockup, call stack, code tree, diff, widget, or buttons and forms that take the user's answer.
|
|
4
4
|
argument-hint: '[explain|diagram|chart|mockup] [what to show]'
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -18,6 +18,10 @@ This applies to the `.html` file that a `kind: "html"` post names.
|
|
|
18
18
|
- The card is about 360 to 640 px wide and sizes its height to the content. Keep the content in normal flow, without `position: fixed` or `100vh`.
|
|
19
19
|
- One chart or diagram per card, under an `<h2>` that says what to see. Keep it flat: solid fills and 1 px lines.
|
|
20
20
|
- Open links with `<a href="https://...">`; the overlay opens them in a new browser tab.
|
|
21
|
+
- To ask the user something, let the card answer for them. You get the answer as your next prompt, with a file that holds the fields as JSON.
|
|
22
|
+
- One choice: a button per answer, sent on click: `<button data-cck-action="approve">Approve</button>`.
|
|
23
|
+
- Several inputs: one form, sent once on submit with every field: `<form data-cck-submit="plan">…<button>Send</button></form>`. Give each input a `name`. `required` works.
|
|
24
|
+
- Action names use letters, digits, `.`, `_` and `-`. The button text is the label you get. Nothing else in the card sends.
|
|
21
25
|
- Take every color and font from these variables, so the card follows the user's light or dark theme:
|
|
22
26
|
|
|
23
27
|
| Variable | Use |
|