flowcollab 0.1.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.
@@ -0,0 +1,136 @@
1
+ #!/usr/bin/env node
2
+ /* flow:standup — daily standup summary.
3
+ Shows done-since, in-progress, in-review, active agents, decisions.
4
+ Suitable for pasting directly into a Slack standup thread.
5
+
6
+ Usage:
7
+ flow-standup # last 24 hours
8
+ flow-standup --since=48 # last 48 hours
9
+ flow-standup --velocity # include week-over-week velocity (last 4 weeks)
10
+ */
11
+
12
+ import { flowFetch, arg } from './_client.mjs';
13
+
14
+ function sanitizeText(s, maxLen = 200) {
15
+ if (!s || typeof s !== 'string') return '';
16
+ return s
17
+ .replace(/\x1b\[[0-9;]*[A-Za-z]/g, '')
18
+ .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
19
+ .replace(/@to:\S+/g, '[mention-stripped]')
20
+ .replace(/\r?\n|\r/g, ' ')
21
+ .trim()
22
+ .slice(0, maxLen);
23
+ }
24
+
25
+ function pad(s, n) { return String(s).padEnd(n).slice(0, n); }
26
+
27
+ async function main() {
28
+ const sinceHours = Math.max(1, parseInt(arg('since') || '24'));
29
+ const since = new Date(Date.now() - sinceHours * 3600000);
30
+ const showVelocity = process.argv.includes('--velocity');
31
+
32
+ const [tasksRes, decRes, presenceRes, velRes] = await Promise.all([
33
+ flowFetch('/api/flow/tasks?limit=200'),
34
+ flowFetch('/api/flow/decisions'),
35
+ flowFetch('/api/flow/presence').catch(() => null),
36
+ showVelocity ? flowFetch('/api/flow/velocity?weeks=4').catch(() => null) : Promise.resolve(null),
37
+ ]);
38
+
39
+ const tasks = tasksRes.tasks || [];
40
+ if (tasksRes.has_more) process.stderr.write('⚠ Board has >200 tasks — standup truncated to most recently updated 200.\n');
41
+ const timeline = tasksRes.timeline || [];
42
+ const decisions = decRes.decisions || [];
43
+ const agents = presenceRes?.agents || [];
44
+ const taskById = new Map(tasks.map(t => [t.id, t]));
45
+ const myId = process.env.FLOW_TOKEN_OWNER_ACTOR || process.env.FLOW_TOKEN_CONTRIBUTOR_ACTOR || '';
46
+
47
+ // Done since N hours — de-dup by task (most recent close per task)
48
+ const doneEvents = timeline
49
+ .filter(ev => ev.kind === 'event' && ev.payload?.act === 'closed' && new Date(ev.ts) >= since)
50
+ .sort((a, b) => b.ts.localeCompare(a.ts));
51
+ const seenDone = new Set();
52
+ const doneItems = [];
53
+ for (const ev of doneEvents) {
54
+ if (!seenDone.has(ev.task_id)) { seenDone.add(ev.task_id); doneItems.push(ev); }
55
+ }
56
+
57
+ const inProgress = tasks.filter(t => t.status === 'in_progress');
58
+ const inReview = tasks.filter(t => t.status === 'in_review');
59
+
60
+ const sinceLabel = sinceHours === 24 ? 'since yesterday' : `last ${sinceHours}h`;
61
+ const out = [];
62
+ out.push(`=== Flow standup (${sinceLabel}) ===`);
63
+ out.push('');
64
+
65
+ out.push(`[done ${sinceLabel} — ${doneItems.length}]`);
66
+ if (doneItems.length) {
67
+ for (const ev of doneItems) {
68
+ const t = taskById.get(ev.task_id);
69
+ const ref = t ? `#${t.issue_num ?? t.id.slice(0, 6)}` : `#${ev.task_id.slice(0, 6)}`;
70
+ out.push(` ${ref} ${pad(ev.actor_id || '?', 14)} ${sanitizeText(t?.title ?? '(unknown)', 65)}`);
71
+ }
72
+ } else {
73
+ out.push(' (nothing closed)');
74
+ }
75
+ out.push('');
76
+
77
+ out.push(`[in progress — ${inProgress.length}]`);
78
+ if (inProgress.length) {
79
+ for (const t of inProgress) {
80
+ out.push(` #${t.issue_num ?? t.id.slice(0, 6)} ${pad(t.assignee_id || 'unassigned', 14)} ${sanitizeText(t.title, 60)}`);
81
+ }
82
+ } else {
83
+ out.push(' (nothing in progress)');
84
+ }
85
+ out.push('');
86
+
87
+ if (inReview.length) {
88
+ out.push(`[in review — ${inReview.length}]`);
89
+ for (const t of inReview) {
90
+ out.push(` #${t.issue_num ?? t.id.slice(0, 6)} ${pad(t.assignee_id || 'unassigned', 14)} ${sanitizeText(t.title, 60)}`);
91
+ }
92
+ out.push('');
93
+ }
94
+
95
+ if (agents.length) {
96
+ out.push(`[active agents — ${agents.length} online]`);
97
+ for (const a of agents) {
98
+ const t = a.task_id ? taskById.get(a.task_id) : null;
99
+ const taskPart = t ? `on #${t.issue_num ?? t.id.slice(0, 6)} "${sanitizeText(t.title, 40)}"` : 'idle';
100
+ const flag = a.actor_id === myId ? ' ← you' : '';
101
+ out.push(` ${pad(a.actor_id, 16)} ${taskPart}${flag}`);
102
+ }
103
+ out.push('');
104
+ }
105
+
106
+ out.push(`[decisions pending — ${decisions.length}]`);
107
+ if (decisions.length) {
108
+ for (const d of decisions.slice(0, 5)) {
109
+ out.push(` #${d.id.slice(0, 6)} [${d.confidence}] ${sanitizeText(d.proposal_title, 65)}`);
110
+ }
111
+ if (decisions.length > 5) out.push(` … and ${decisions.length - 5} more`);
112
+ } else {
113
+ out.push(' (none)');
114
+ }
115
+
116
+ if (showVelocity && velRes?.weeks?.length) {
117
+ out.push('');
118
+ out.push('[velocity — tasks closed per week]');
119
+ const velWeeks = velRes.weeks;
120
+ const maxTotal = Math.max(...velWeeks.map(w => w.total), 1);
121
+ for (const w of velWeeks) {
122
+ const bar = '█'.repeat(Math.round(w.total / maxTotal * 12));
123
+ const actors = Object.entries(w.by_actor).map(([a, n]) => `${a}:${n}`).join(', ');
124
+ out.push(` ${w.week} ${String(w.total).padStart(2)} tasks ${bar.padEnd(12)} ${actors}`);
125
+ }
126
+ const totals = velWeeks.map(w => w.total);
127
+ if (totals.length >= 2) {
128
+ const trend = totals[totals.length - 1] - totals[0];
129
+ out.push(` trend: ${trend >= 0 ? '+' : ''}${trend} tasks/week over ${velWeeks.length} weeks`);
130
+ }
131
+ }
132
+
133
+ process.stdout.write(out.join('\n') + '\n');
134
+ }
135
+
136
+ main().catch(e => { process.stderr.write('standup failed: ' + (e.message || e) + '\n'); process.exit(1); });
package/bin/status.mjs ADDED
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env node
2
+ /* flow:status — lightweight board summary at a glance.
3
+ Usage: flow-status
4
+ */
5
+
6
+ import { flowFetch } from './_client.mjs';
7
+
8
+ function pad(s, n) { return String(s).padEnd(n).slice(0, n); }
9
+
10
+ function relAgo(ts) {
11
+ if (!ts) return '?';
12
+ const m = Math.round((Date.now() - new Date(ts).getTime()) / 60000);
13
+ if (m < 1) return 'just now';
14
+ if (m < 60) return `${m}m ago`;
15
+ return `${Math.round(m / 60)}h ago`;
16
+ }
17
+
18
+ async function main() {
19
+ const [tasksRes, decRes, presenceRes] = await Promise.all([
20
+ flowFetch('/api/flow/tasks?limit=200'),
21
+ flowFetch('/api/flow/decisions'),
22
+ flowFetch('/api/flow/presence').catch(() => null),
23
+ ]);
24
+
25
+ const tasks = tasksRes.tasks || [];
26
+ if (tasksRes.has_more) process.stderr.write('⚠ Board has >200 tasks — status truncated to most recently updated 200.\n');
27
+ const timeline = tasksRes.timeline || [];
28
+ const decisions = decRes.decisions || [];
29
+ const agents = presenceRes?.agents || [];
30
+
31
+ const STALE_HOURS = 4;
32
+ const now = Date.now();
33
+
34
+ const cols = ['backlog', 'todo', 'in_progress', 'in_review', 'done'];
35
+ const byStatus = Object.fromEntries(cols.map(s => [s, tasks.filter(t => t.status === s)]));
36
+
37
+ const todayStart = new Date(); todayStart.setHours(0, 0, 0, 0);
38
+ const doneToday = timeline.filter(ev =>
39
+ ev.kind === 'event' && ev.payload?.act === 'closed' && new Date(ev.ts || 0) >= todayStart
40
+ );
41
+
42
+ const out = [];
43
+ out.push('=== Flow board status ===');
44
+ out.push('');
45
+
46
+ out.push('[board]');
47
+ for (const s of cols) {
48
+ const arr = byStatus[s] || [];
49
+ if (!arr.length && s !== 'in_progress' && s !== 'in_review') continue;
50
+ const label = s.replace(/_/g, ' ');
51
+ out.push(` ${pad(label, 14)} ${arr.length}`);
52
+ if (s === 'in_progress' || s === 'in_review') {
53
+ for (const t of arr) {
54
+ const stale = t.updated_at && (now - new Date(t.updated_at).getTime()) > STALE_HOURS * 3600000;
55
+ const staleMark = stale ? ' ⚠ STALE' : '';
56
+ out.push(` #${t.issue_num ?? t.id.slice(0, 6)} ${pad(t.assignee_id || 'unassigned', 14)} ${t.title.slice(0, 50)}${staleMark}`);
57
+ }
58
+ }
59
+ }
60
+ if (doneToday.length) out.push(` ${'done today'.padEnd(14)} ${doneToday.length}`);
61
+ out.push('');
62
+
63
+ if (agents.length) {
64
+ const taskById = new Map(tasks.map(t => [t.id, t]));
65
+ const inProgressByActor = {};
66
+ for (const t of tasks.filter(t => t.status === 'in_progress')) {
67
+ if (!t.assignee_id) continue;
68
+ if (!inProgressByActor[t.assignee_id]) inProgressByActor[t.assignee_id] = [];
69
+ inProgressByActor[t.assignee_id].push(t);
70
+ }
71
+ out.push(`[active agents — ${agents.length} online]`);
72
+ for (const a of agents) {
73
+ const myTasks = inProgressByActor[a.actor_id] || [];
74
+ const heartbeatTask = a.task_id ? taskById.get(a.task_id) : null;
75
+ const displayTask = heartbeatTask || myTasks[0];
76
+ const taskPart = displayTask
77
+ ? `#${displayTask.issue_num ?? displayTask.id.slice(0, 6)} "${displayTask.title.slice(0, 38)}"`
78
+ : 'idle';
79
+ const extraCount = myTasks.length > 1 ? ` +${myTasks.length - 1} more` : '';
80
+ out.push(` ${pad(a.actor_id, 16)} ${taskPart}${extraCount} (${relAgo(a.last_seen_at)})`);
81
+ const claimedFiles = displayTask?.flow_files;
82
+ if (claimedFiles?.length) {
83
+ const shown = claimedFiles.slice(0, 5).join(', ');
84
+ const more = claimedFiles.length > 5 ? ` +${claimedFiles.length - 5} more` : '';
85
+ out.push(` files: ${shown}${more}`);
86
+ }
87
+ }
88
+ out.push('');
89
+ }
90
+
91
+ out.push(`[decisions] ${decisions.length} pending`);
92
+ if (decisions.length) {
93
+ for (const d of decisions.slice(0, 5)) {
94
+ out.push(` #${d.id.slice(0, 6)} [${d.confidence}] ${d.proposal_title.slice(0, 65)}`);
95
+ }
96
+ if (decisions.length > 5) out.push(` … and ${decisions.length - 5} more`);
97
+ }
98
+
99
+ process.stdout.write(out.join('\n') + '\n');
100
+ }
101
+
102
+ main().catch(e => { process.stderr.write('status failed: ' + (e.message || e) + '\n'); process.exit(1); });
package/bin/sync.mjs ADDED
@@ -0,0 +1,104 @@
1
+ #!/usr/bin/env node
2
+ /* flow:sync — mid-session delta update.
3
+ Shows only what changed since N minutes ago:
4
+ new comments on any task, resolved decisions,
5
+ newly claimed tasks, and currently active agents.
6
+
7
+ Usage:
8
+ flow-sync # last 60 minutes (default)
9
+ flow-sync --since=30 # last 30 minutes
10
+ */
11
+
12
+ import { flowFetch, arg } from './_client.mjs';
13
+
14
+ // Same sanitizer as pull.mjs — keep parity for injection defense.
15
+ function sanitizeText(s, maxLen = 400) {
16
+ if (!s || typeof s !== 'string') return '';
17
+ return s
18
+ .replace(/\x1b\[[0-9;]*[A-Za-z]/g, '')
19
+ .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
20
+ .replace(/@to:\S+/g, '[mention-stripped]')
21
+ .replace(/\r?\n|\r/g, ' ↵ ')
22
+ .trim()
23
+ .slice(0, maxLen);
24
+ }
25
+
26
+ function relAgo(ts) {
27
+ if (!ts) return '?';
28
+ const m = Math.round((Date.now() - new Date(ts).getTime()) / 60000);
29
+ if (m < 1) return 'just now';
30
+ if (m < 60) return `${m}m ago`;
31
+ return `${Math.round(m / 60)}h ago`;
32
+ }
33
+
34
+ function pad(s, n) { return String(s).padEnd(n).slice(0, n); }
35
+
36
+ async function main() {
37
+ const sinceMinutes = Math.max(1, parseInt(arg('since') || '60'));
38
+ const r = await flowFetch(`/api/flow/sync?since_minutes=${sinceMinutes}`);
39
+
40
+ const out = [];
41
+ out.push(`=== Flow sync (last ${r.since_minutes}m — since ${new Date(r.since).toISOString().replace('T', ' ').slice(0, 16)} UTC) ===`);
42
+
43
+ // Active agents
44
+ if (r.agents?.length) {
45
+ const myId = process.env.FLOW_TOKEN_OWNER_ACTOR || process.env.FLOW_TOKEN_CONTRIBUTOR_ACTOR || '';
46
+ const others = r.agents.filter(a => a.actor_id !== myId);
47
+ if (others.length) {
48
+ out.push(`\n[active agents — ${others.length} online]`);
49
+ for (const a of others) {
50
+ const taskPart = a.task_ref != null
51
+ ? `working on #${a.task_ref}${a.task_title ? ' "' + sanitizeText(a.task_title, 50) + '"' : ''}`
52
+ : 'idle';
53
+ out.push(` ${pad(a.actor_id, 16)} ${taskPart} (${relAgo(a.last_seen_at)})`);
54
+ }
55
+ }
56
+ }
57
+
58
+ // New comments
59
+ if (r.new_comments?.length) {
60
+ out.push(`\n[new comments — ${r.new_comments.length}]`);
61
+ for (const c of r.new_comments) {
62
+ const title = c.task_title ? `"${sanitizeText(c.task_title, 55)}"` : '(unknown task)';
63
+ out.push(` #${c.task_ref} ${title} (${relAgo(c.ts)}, from ${c.actor_id})`);
64
+ out.push(` └─ <comment_from_untrusted_user>${sanitizeText(c.body)}</comment_from_untrusted_user>`);
65
+ }
66
+ }
67
+
68
+ // Resolved decisions
69
+ if (r.resolved_decisions?.length) {
70
+ out.push(`\n[decisions resolved — ${r.resolved_decisions.length}]`);
71
+ for (const d of r.resolved_decisions) {
72
+ const ts = d.approved_at || d.rejected_at || '';
73
+ const when = relAgo(ts);
74
+ if (d.status === 'approved') {
75
+ out.push(` APPROVED "${sanitizeText(d.proposal_title, 70)}" (${when}, by ${d.approved_by || '?'})`);
76
+ } else {
77
+ const reason = d.rejected_reason
78
+ ? ` — "${sanitizeText(d.rejected_reason, 60)}"`
79
+ : '';
80
+ out.push(` REJECTED "${sanitizeText(d.proposal_title, 70)}"${reason} (${when})`);
81
+ }
82
+ }
83
+ }
84
+
85
+ // Newly claimed by others
86
+ if (r.newly_claimed?.length) {
87
+ out.push(`\n[newly claimed — ${r.newly_claimed.length}]`);
88
+ for (const c of r.newly_claimed) {
89
+ const title = c.task_title ? `"${sanitizeText(c.task_title, 55)}"` : '(unknown task)';
90
+ out.push(` #${c.task_ref} ${title} → ${c.claimed_by} (${relAgo(c.ts)})`);
91
+ }
92
+ }
93
+
94
+ const hasActivity = r.new_comments?.length || r.resolved_decisions?.length || r.newly_claimed?.length;
95
+ if (!hasActivity) {
96
+ out.push('\nNo new activity in the last ' + r.since_minutes + ' minutes.');
97
+ }
98
+
99
+ out.push('\nRun `flow-pull` for a full board snapshot, or `flow-claim <id>` to pick up work.');
100
+
101
+ process.stdout.write(out.join('\n') + '\n');
102
+ }
103
+
104
+ main().catch(e => { process.stderr.write('sync failed: ' + (e.message || e) + '\n'); process.exit(1); });
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env node
2
+ /* flow:unblock — clear the blocked_by dependency on a task.
3
+ Usage: flow-unblock <task_id>
4
+ */
5
+
6
+ import { flowFetch, resolveTaskId, positional, die } from './_client.mjs';
7
+
8
+ async function main() {
9
+ const raw = positional(0);
10
+ if (!raw) die('Usage: flow-unblock <task_id>');
11
+
12
+ const id = await resolveTaskId(raw);
13
+ const res = await flowFetch(`/api/flow/tasks/${id}`, { method: 'PATCH', body: { blocked_by: [] } });
14
+
15
+ const title = res.task?.title || id.slice(0, 8);
16
+ process.stdout.write(`Unblocked: ${title}\n`);
17
+ }
18
+
19
+ main().catch(e => die(e.message || e));
package/bin/whoami.mjs ADDED
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env node
2
+ /* flow:whoami — print current session identity and verify API connectivity.
3
+ Usage: flow-whoami
4
+ Run at session start to confirm you're acting as the right actor with a live token.
5
+ */
6
+
7
+ import 'dotenv/config';
8
+ import { flowFetch } from './_client.mjs';
9
+
10
+ async function main() {
11
+ const actor = process.env.FLOW_DEFAULT_ASSIGNEE || '(not set — add FLOW_DEFAULT_ASSIGNEE to .env)';
12
+ const base = process.env.FLOW_API_BASE || 'http://localhost:3000';
13
+ const actingVia = (process.env.FLOW_ACTING_VIA || 'claude').toLowerCase();
14
+
15
+ const ownerToken = process.env.FLOW_API_TOKEN_OWNER || '';
16
+ const contribToken = process.env.FLOW_API_TOKEN_CONTRIBUTOR || '';
17
+ const tokenKind = ownerToken ? 'owner' : contribToken ? 'contributor' : 'none';
18
+ const rawToken = ownerToken || contribToken;
19
+ const tokenDisplay = rawToken.length > 4
20
+ ? `${'*'.repeat(rawToken.length - 4)}${rawToken.slice(-4)}`
21
+ : rawToken ? '****' : '(missing)';
22
+
23
+ process.stdout.write('Flow identity:\n');
24
+ process.stdout.write(` actor: ${actor}\n`);
25
+ process.stdout.write(` token: ${tokenKind} ${tokenDisplay}\n`);
26
+ process.stdout.write(` api_base: ${base}\n`);
27
+ process.stdout.write(` acting_via: ${actingVia}\n`);
28
+
29
+ try {
30
+ const res = await flowFetch('/api/flow/tasks?limit=200');
31
+ const count = res.has_more ? '200+' : (res.tasks || []).length;
32
+ process.stdout.write(` status: connected (${count} task${count === 1 ? '' : 's'} visible)\n`);
33
+ } catch (e) {
34
+ process.stdout.write(` status: ERROR — ${e.message}\n`);
35
+ process.exit(1);
36
+ }
37
+ }
38
+
39
+ main().catch(e => { process.stderr.write('flow-cli: ' + (e.message || e) + '\n'); process.exit(1); });
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "flowcollab",
3
+ "version": "0.1.0",
4
+ "description": "Multi-Claude coordination layer — shared task board + CLI for teams running Claude Code",
5
+ "type": "module",
6
+ "files": [
7
+ "bin/"
8
+ ],
9
+ "bin": {
10
+ "flow-create": "bin/create.mjs",
11
+ "flow-pull": "bin/pull.mjs",
12
+ "flow-claim": "bin/claim.mjs",
13
+ "flow-comment": "bin/comment.mjs",
14
+ "flow-close": "bin/close.mjs",
15
+ "flow-propose": "bin/propose.mjs",
16
+ "flow-approve": "bin/approve.mjs",
17
+ "flow-reject": "bin/reject.mjs",
18
+ "flow-assign": "bin/assign.mjs",
19
+ "flow-decisions": "bin/decisions.mjs",
20
+ "flow-ping": "bin/ping.mjs",
21
+ "flow-pr": "bin/pr.mjs",
22
+ "flow-whoami": "bin/whoami.mjs",
23
+ "flow-sync": "bin/sync.mjs",
24
+ "flow-heartbeat": "bin/heartbeat.mjs",
25
+ "flow-status": "bin/status.mjs",
26
+ "flow-handoff": "bin/handoff.mjs",
27
+ "flow-standup": "bin/standup.mjs",
28
+ "flow-search": "bin/search.mjs",
29
+ "flow-init": "bin/init.mjs",
30
+ "flow-project": "bin/project.mjs",
31
+ "flow-close-sprint": "bin/close-sprint.mjs",
32
+ "flow-review": "bin/review.mjs",
33
+ "flow-login": "bin/login.mjs",
34
+ "flow-edit": "bin/edit.mjs",
35
+ "flow-unblock": "bin/unblock.mjs",
36
+ "flow-log": "bin/log.mjs",
37
+ "flow-completion": "bin/completion.mjs"
38
+ },
39
+ "scripts": {
40
+ "build": "node scripts/build.mjs",
41
+ "dev": "node scripts/build.mjs --watch",
42
+ "start": "node scripts/build.mjs && node --use-system-ca server.js",
43
+ "start:prod": "node scripts/build.mjs && node server.js"
44
+ },
45
+ "dependencies": {
46
+ "dotenv": "^16.4.7"
47
+ },
48
+ "devDependencies": {
49
+ "@supabase/supabase-js": "^2.49.4",
50
+ "esbuild": "^0.28.0",
51
+ "express": "^4.0.0 || ^5.0.0",
52
+ "express-rate-limit": "^7.5.0",
53
+ "helmet": "^8.0.0",
54
+ "stripe": "^22.2.0",
55
+ "zod": "^3.24.1"
56
+ },
57
+ "engines": {
58
+ "node": ">=18"
59
+ },
60
+ "license": "UNLICENSED"
61
+ }