flowcollab 0.2.2 → 0.2.4

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/bin/flow.mjs CHANGED
@@ -48,6 +48,7 @@ const CMDS = [
48
48
  ['pr', 'pr.mjs', 'Record a PR link on a task timeline'],
49
49
  ['project', 'project.mjs', 'List GitHub Projects v2 items'],
50
50
  ['archive', 'archive.mjs', 'Archive or unarchive a task (--undo to restore)'],
51
+ ['scan', 'scan.mjs', 'Audit for TODOs, untracked Issues, unlinked PRs, security patterns'],
51
52
  ['close-sprint', 'close-sprint.mjs', 'Close a sprint milestone (owner only)'],
52
53
  ['whoami', 'whoami.mjs', 'Verify token and show current identity'],
53
54
  ['completion', 'completion.mjs', 'Print shell completion script (bash/zsh/fish)'],
package/bin/scan.mjs ADDED
@@ -0,0 +1,238 @@
1
+ #!/usr/bin/env node
2
+ /* flow-scan — audit for TODOs, untracked GitHub Issues, unlinked PRs, and security patterns.
3
+ Outputs numbered suggestions with ready-to-run flow-create commands.
4
+
5
+ Usage:
6
+ flow-scan [--todos] [--issues] [--prs] [--security]
7
+ [--dir=<path>] default: cwd
8
+ [--repo=<owner/repo>] default: FLOW_GITHUB_REPO env var
9
+ (no flags = run all four scans)
10
+ */
11
+
12
+ import 'dotenv/config';
13
+ import { flowFetch, arg, die } from './_client.mjs';
14
+ import { githubHeaders } from './_github.mjs';
15
+ import { readFileSync, readdirSync } from 'node:fs';
16
+ import { join, extname, relative } from 'node:path';
17
+
18
+ // ── File walker ────────────────────────────────────────────────────────────────
19
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', 'coverage', '.next', '__pycache__', 'vendor', '.cache', 'out']);
20
+ const CODE_EXTS = new Set(['.js', '.mjs', '.ts', '.tsx', '.jsx', '.py', '.go', '.rb', '.java', '.sh', '.rs', '.c', '.cpp', '.h', '.cs', '.php', '.vue', '.svelte']);
21
+
22
+ function walkFiles(dir, base) {
23
+ base = base || dir;
24
+ const out = [];
25
+ let entries;
26
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return out; }
27
+ for (const e of entries) {
28
+ if (SKIP_DIRS.has(e.name)) continue;
29
+ const full = join(dir, e.name);
30
+ if (e.isDirectory()) { out.push(...walkFiles(full, base)); continue; }
31
+ if (CODE_EXTS.has(extname(e.name))) out.push(full);
32
+ }
33
+ return out;
34
+ }
35
+
36
+ function grepLines(files, re) {
37
+ const hits = [];
38
+ for (const f of files) {
39
+ let lines;
40
+ try { lines = readFileSync(f, 'utf8').split('\n'); } catch { continue; }
41
+ for (let i = 0; i < lines.length; i++) {
42
+ if (re.test(lines[i])) hits.push({ file: f, lineNo: i + 1, text: lines[i].trim() });
43
+ }
44
+ }
45
+ return hits;
46
+ }
47
+
48
+ // ── TODO scan ──────────────────────────────────────────────────────────────────
49
+ const TODO_RE = /\b(TODO|FIXME|HACK|XXX)\b[:\s]*(.*)/i;
50
+
51
+ function scanTodos(files, dir) {
52
+ const hits = grepLines(files, TODO_RE);
53
+ if (!hits.length) return [];
54
+ const out = [];
55
+ for (const h of hits.slice(0, 25)) {
56
+ const m = h.text.match(TODO_RE);
57
+ const kind = (m?.[1] || 'TODO').toUpperCase();
58
+ const desc = ((m?.[2] || '').trim() || h.text).slice(0, 80);
59
+ const rel = relative(dir, h.file);
60
+ const title = (desc.length > 58 ? desc.slice(0, 55) + '...' : desc).replace(/"/g, '\\"');
61
+ out.push({
62
+ label: `${kind} in ${rel}:${h.lineNo}`,
63
+ detail: ` ${rel}:${h.lineNo} — ${desc}`,
64
+ cmd: `flow-create --title="${title}" --type=chore --area=infra`,
65
+ });
66
+ }
67
+ return out;
68
+ }
69
+
70
+ // ── GitHub Issues scan ─────────────────────────────────────────────────────────
71
+ async function fetchGitHubIssues(repo) {
72
+ const all = [];
73
+ for (let page = 1; page <= 5; page++) {
74
+ const res = await fetch(`https://api.github.com/repos/${repo}/issues?state=open&per_page=100&page=${page}`, {
75
+ headers: githubHeaders(),
76
+ });
77
+ if (!res.ok) throw new Error(`GitHub API ${res.status}`);
78
+ const batch = await res.json();
79
+ all.push(...batch.filter(i => !i.pull_request));
80
+ if (batch.length < 100) break;
81
+ }
82
+ return all;
83
+ }
84
+
85
+ async function scanIssues(repo) {
86
+ if (!repo) return null;
87
+ const [ghIssues, flowResp] = await Promise.all([
88
+ fetchGitHubIssues(repo),
89
+ flowFetch('/api/flow/tasks?limit=500'),
90
+ ]);
91
+ const tracked = new Set((flowResp.tasks || []).filter(t => t.github_issue_num).map(t => t.github_issue_num));
92
+ const untracked = ghIssues.filter(i => !tracked.has(i.number));
93
+ return untracked.slice(0, 15).map(i => {
94
+ const labels = (i.labels || []).map(l => l.name.toLowerCase());
95
+ const type = labels.includes('bug') ? 'bug' : labels.includes('documentation') ? 'docs' : 'feature';
96
+ return {
97
+ label: `GitHub Issue #${i.number}: ${i.title}`,
98
+ detail: ` ${i.html_url}`,
99
+ cmd: `flow-create --from-issue=${i.number} --type=${type}`,
100
+ };
101
+ });
102
+ }
103
+
104
+ // ── GitHub PRs scan ────────────────────────────────────────────────────────────
105
+ async function fetchGitHubPRs(repo) {
106
+ const all = [];
107
+ for (let page = 1; page <= 3; page++) {
108
+ const res = await fetch(`https://api.github.com/repos/${repo}/pulls?state=open&per_page=100&page=${page}`, {
109
+ headers: githubHeaders(),
110
+ });
111
+ if (!res.ok) throw new Error(`GitHub API ${res.status}`);
112
+ const batch = await res.json();
113
+ all.push(...batch);
114
+ if (batch.length < 100) break;
115
+ }
116
+ return all;
117
+ }
118
+
119
+ async function scanPRs(repo) {
120
+ if (!repo) return null;
121
+ const [ghPRs, flowResp] = await Promise.all([
122
+ fetchGitHubPRs(repo),
123
+ flowFetch('/api/flow/tasks?limit=500'),
124
+ ]);
125
+ const trackedPRs = new Set((flowResp.tasks || []).filter(t => t.pr_num).map(t => t.pr_num));
126
+ const unlinked = ghPRs.filter(pr => !trackedPRs.has(pr.number));
127
+ return unlinked.slice(0, 10).map(pr => {
128
+ const title = pr.title.replace(/"/g, '\\"').slice(0, 55);
129
+ return {
130
+ label: `Unlinked PR #${pr.number}: ${pr.title}`,
131
+ detail: ` ${pr.html_url} (${pr.draft ? 'draft' : 'open'})`,
132
+ cmd: `flow-create --title="Track PR #${pr.number}: ${title}" --type=chore --area=backend`,
133
+ };
134
+ });
135
+ }
136
+
137
+ // ── Security scan ──────────────────────────────────────────────────────────────
138
+ const SEC_PATTERNS = [
139
+ { re: /(['"`])(sk_live_|AKIA[0-9A-Z]{16}|ghp_[0-9A-Za-z]{36})[0-9A-Za-z]+\1/i, label: 'Hardcoded secret (live key pattern)' },
140
+ { re: /\beval\s*\(/, label: 'eval() usage (code injection risk)' },
141
+ { re: /dangerouslySetInnerHTML/, label: 'dangerouslySetInnerHTML (XSS risk)' },
142
+ { re: /child_process.*\.exec\s*\(/, label: 'exec() call (command injection risk)' },
143
+ { re: /\.query\s*\(`[^`]*\$\{/, label: 'Template literal in SQL query (injection risk)' },
144
+ { re: /new\s+Function\s*\(/, label: 'new Function() (eval-equivalent)' },
145
+ { re: /require\s*\(\s*(?:req\.|request\.|body\.|params\.|query\.)/, label: 'Dynamic require() from request input' },
146
+ { re: /(?:password|secret|apikey)\s*(?:=|:)\s*['"`][^'"`]{6,}/i, label: 'Possible hardcoded credential' },
147
+ ];
148
+
149
+ function scanSecurity(files, dir) {
150
+ const out = [];
151
+ for (const { re, label } of SEC_PATTERNS) {
152
+ const hits = grepLines(files, re);
153
+ for (const h of hits.slice(0, 3)) {
154
+ const rel = relative(dir, h.file);
155
+ const snippet = h.text.slice(0, 70);
156
+ out.push({
157
+ label: `${label} — ${rel}:${h.lineNo}`,
158
+ detail: ` ${rel}:${h.lineNo}: ${snippet}`,
159
+ cmd: `flow-create --title="Security: ${label.split(' (')[0].replace(/"/g, '\\"')}" --type=chore --area=infra --priority=P0-now`,
160
+ });
161
+ }
162
+ }
163
+ return out;
164
+ }
165
+
166
+ // ── Output ─────────────────────────────────────────────────────────────────────
167
+ function printSection(heading, suggestions) {
168
+ process.stdout.write(`\n${heading} (${suggestions.length})\n`);
169
+ process.stdout.write('─'.repeat(52) + '\n');
170
+ if (!suggestions.length) { process.stdout.write(' None found.\n'); return; }
171
+ for (let i = 0; i < suggestions.length; i++) {
172
+ const s = suggestions[i];
173
+ process.stdout.write(`${i + 1}. ${s.label}\n${s.detail}\n $ ${s.cmd}\n\n`);
174
+ }
175
+ }
176
+
177
+ // ── Main ───────────────────────────────────────────────────────────────────────
178
+ async function main() {
179
+ const doTodos = !!arg('todos');
180
+ const doIssues = !!arg('issues');
181
+ const doPRs = !!arg('prs');
182
+ const doSecurity = !!arg('security');
183
+ const all = !doTodos && !doIssues && !doPRs && !doSecurity;
184
+
185
+ const dir = arg('dir') || process.cwd();
186
+ const repo = arg('repo') || process.env.FLOW_GITHUB_REPO;
187
+
188
+ process.stdout.write(`flow-scan\n dir: ${dir}\n`);
189
+ if (repo) process.stdout.write(` repo: ${repo}\n`);
190
+ process.stdout.write('\n');
191
+
192
+ const needFiles = all || doTodos || doSecurity;
193
+ const files = needFiles ? walkFiles(dir) : [];
194
+ if (needFiles) process.stdout.write(` scanning ${files.length} source files...\n`);
195
+
196
+ if (all || doTodos) {
197
+ process.stdout.write(' [todos] scanning...\n');
198
+ const s = scanTodos(files, dir);
199
+ if (doTodos) { printSection('TODO / FIXME items', s); return; }
200
+ if (all && s.length) printSection('TODO / FIXME items', s);
201
+ else if (all) process.stdout.write('\n[todos] None found.\n');
202
+ }
203
+
204
+ if (all || doIssues) {
205
+ process.stdout.write(' [issues] fetching GitHub Issues vs Flow tasks...\n');
206
+ let s;
207
+ try { s = await scanIssues(repo); } catch (e) { process.stdout.write(` [issues] Error: ${e.message}\n`); s = []; }
208
+ if (doIssues) { printSection('Untracked GitHub Issues', s || []); return; }
209
+ if (s === null) process.stdout.write('\n[issues] Skipped — FLOW_GITHUB_REPO not set.\n');
210
+ else if (all && s.length) printSection('Untracked GitHub Issues', s);
211
+ else if (all) process.stdout.write('\n[issues] All open Issues are tracked in Flow.\n');
212
+ }
213
+
214
+ if (all || doPRs) {
215
+ process.stdout.write(' [prs] fetching open PRs vs Flow tasks...\n');
216
+ let s;
217
+ try { s = await scanPRs(repo); } catch (e) { process.stdout.write(` [prs] Error: ${e.message}\n`); s = []; }
218
+ if (doPRs) { printSection('Unlinked open PRs', s || []); return; }
219
+ if (s === null) process.stdout.write('\n[prs] Skipped — FLOW_GITHUB_REPO not set.\n');
220
+ else if (all && s.length) printSection('Unlinked open PRs', s);
221
+ else if (all) process.stdout.write('\n[prs] All open PRs are linked to Flow tasks.\n');
222
+ }
223
+
224
+ if (all || doSecurity) {
225
+ process.stdout.write(' [security] scanning patterns...\n');
226
+ const s = scanSecurity(files, dir);
227
+ if (doSecurity) { printSection('Security concerns', s); return; }
228
+ if (all && s.length) printSection('Security concerns', s);
229
+ else if (all) process.stdout.write('\n[security] No common patterns found.\n');
230
+ }
231
+
232
+ if (all) {
233
+ process.stdout.write('\nRun "flow-scan --todos / --issues / --prs / --security" for focused output.\n');
234
+ process.stdout.write('Use any "$ flow-create ..." command above to create a tracking task.\n');
235
+ }
236
+ }
237
+
238
+ main().catch(e => { process.stderr.write('flow-scan: ' + (e.message || e) + '\n'); process.exit(1); });
package/bin/standup.mjs CHANGED
@@ -24,6 +24,11 @@ function sanitizeText(s, maxLen = 200) {
24
24
 
25
25
  function pad(s, n) { return String(s).padEnd(n).slice(0, n); }
26
26
 
27
+ const TERM_COLS = process.stdout.columns || 120;
28
+ // Reserve: 2 indent + 7 ref + 2 gap + 14 actor + 2 gap = 27 fixed chars
29
+ const TITLE_W = Math.max(20, TERM_COLS - 29);
30
+ const SHORT_TITLE_W = Math.max(20, TERM_COLS - 37); // agents have longer prefix
31
+
27
32
  async function main() {
28
33
  const sinceHours = Math.max(1, parseInt(arg('since') || '24'));
29
34
  const since = new Date(Date.now() - sinceHours * 3600000);
@@ -67,7 +72,7 @@ async function main() {
67
72
  for (const ev of doneItems) {
68
73
  const t = taskById.get(ev.task_id);
69
74
  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)}`);
75
+ out.push(` ${ref} ${pad(ev.actor_id || '?', 14)} ${sanitizeText(t?.title ?? '(unknown)', TITLE_W)}`);
71
76
  }
72
77
  } else {
73
78
  out.push(' (nothing closed)');
@@ -77,7 +82,7 @@ async function main() {
77
82
  out.push(`[in progress — ${inProgress.length}]`);
78
83
  if (inProgress.length) {
79
84
  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)}`);
85
+ out.push(` #${t.issue_num ?? t.id.slice(0, 6)} ${pad(t.assignee_id || 'unassigned', 14)} ${sanitizeText(t.title, TITLE_W)}`);
81
86
  }
82
87
  } else {
83
88
  out.push(' (nothing in progress)');
@@ -87,7 +92,7 @@ async function main() {
87
92
  if (inReview.length) {
88
93
  out.push(`[in review — ${inReview.length}]`);
89
94
  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)}`);
95
+ out.push(` #${t.issue_num ?? t.id.slice(0, 6)} ${pad(t.assignee_id || 'unassigned', 14)} ${sanitizeText(t.title, TITLE_W)}`);
91
96
  }
92
97
  out.push('');
93
98
  }
@@ -96,7 +101,7 @@ async function main() {
96
101
  out.push(`[active agents — ${agents.length} online]`);
97
102
  for (const a of agents) {
98
103
  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';
104
+ const taskPart = t ? `on #${t.issue_num ?? t.id.slice(0, 6)} "${sanitizeText(t.title, SHORT_TITLE_W)}"` : 'idle';
100
105
  const flag = a.actor_id === myId ? ' ← you' : '';
101
106
  out.push(` ${pad(a.actor_id, 16)} ${taskPart}${flag}`);
102
107
  }
@@ -106,7 +111,7 @@ async function main() {
106
111
  out.push(`[decisions pending — ${decisions.length}]`);
107
112
  if (decisions.length) {
108
113
  for (const d of decisions.slice(0, 5)) {
109
- out.push(` #${d.id.slice(0, 6)} [${d.confidence}] ${sanitizeText(d.proposal_title, 65)}`);
114
+ out.push(` #${d.id.slice(0, 6)} [${d.confidence}] ${sanitizeText(d.proposal_title, TITLE_W)}`);
110
115
  }
111
116
  if (decisions.length > 5) out.push(` … and ${decisions.length - 5} more`);
112
117
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowcollab",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "description": "Multi-Claude coordination layer — shared task board + CLI for teams running Claude Code",
5
5
  "type": "module",
6
6
  "files": [
@@ -35,7 +35,8 @@
35
35
  "flow-unblock": "bin/unblock.mjs",
36
36
  "flow-log": "bin/log.mjs",
37
37
  "flow-completion": "bin/completion.mjs",
38
- "flow-archive": "bin/archive.mjs"
38
+ "flow-archive": "bin/archive.mjs",
39
+ "flow-scan": "bin/scan.mjs"
39
40
  },
40
41
  "scripts": {
41
42
  "build": "node scripts/build.mjs",