flowcollab 0.3.19 → 0.3.20

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/approve.mjs CHANGED
@@ -5,7 +5,7 @@
5
5
  flow-approve <decision_id> --title="override" --assignee=...
6
6
  */
7
7
 
8
- import { flowFetch, resolveTaskId, positional, arg, die } from './_client.mjs';
8
+ import { flowFetch, resolveTaskId, positional, arg, die, sanitizeText } from './_client.mjs';
9
9
 
10
10
  async function main() {
11
11
  const raw = positional(0);
@@ -21,7 +21,7 @@ async function main() {
21
21
  const r = await flowFetch(`/api/flow/decisions/${id}/approve`, {
22
22
  method: 'POST', body: overrides,
23
23
  });
24
- process.stdout.write(`Promoted to task #${r.task.id.slice(0, 6)} — "${r.task.title}"\n`);
24
+ process.stdout.write(`Promoted to task #${r.task.id.slice(0, 6)} — "${sanitizeText(r.task.title, 80)}"\n`);
25
25
  }
26
26
 
27
27
  main().catch(e => die(e.message || e));
package/bin/archive.mjs CHANGED
@@ -8,7 +8,7 @@
8
8
  flow-archive <task-id> --undo # unarchive
9
9
  */
10
10
 
11
- import { flowFetch, RESOLVED_ACTOR } from './_client.mjs';
11
+ import { flowFetch, RESOLVED_ACTOR, sanitizeText } from './_client.mjs';
12
12
 
13
13
  const [,, taskId, ...rest] = process.argv;
14
14
  const undo = rest.includes('--undo');
@@ -30,4 +30,4 @@ const res = await flowFetch(endpoint, { method: 'POST' }).catch(e => {
30
30
  const t = res.task;
31
31
  const ref = `#${t.issue_num ?? taskId.slice(0, 6)}`;
32
32
  const verb = undo ? 'Unarchived' : 'Archived';
33
- process.stdout.write(`${verb} ${ref}: ${t.title}\n`);
33
+ process.stdout.write(`${verb} ${ref}: ${sanitizeText(t.title, 80)}\n`);
package/bin/assign.mjs CHANGED
@@ -8,7 +8,7 @@
8
8
  Server-side: human-only (claude alone cannot push work onto humans).
9
9
  */
10
10
 
11
- import { flowFetch, resolveTaskId, positional, die } from './_client.mjs';
11
+ import { flowFetch, resolveTaskId, positional, die, sanitizeText } from './_client.mjs';
12
12
 
13
13
  async function main() {
14
14
  const raw = positional(0);
@@ -23,7 +23,7 @@ async function main() {
23
23
  method: 'POST',
24
24
  body: { assignee_id },
25
25
  });
26
- process.stdout.write(`Assigned: ${r.task.title}\n`);
26
+ process.stdout.write(`Assigned: ${sanitizeText(r.task.title, 80)}\n`);
27
27
  process.stdout.write(` Now assignee: ${r.task.assignee_id || '(unassigned)'}\n`);
28
28
  } catch (e) {
29
29
  if (e.status === 403) die('Assignment requires a human actor (FLOW_ACTING_VIA=self or no claude header).');
package/bin/claim.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  Usage: flow-claim <task_id>
4
4
  */
5
5
 
6
- import { flowFetch, resolveTaskId, positional, arg, die } from './_client.mjs';
6
+ import { flowFetch, resolveTaskId, positional, arg, die, sanitizeText } from './_client.mjs';
7
7
 
8
8
  async function main() {
9
9
  const raw = positional(0);
@@ -36,13 +36,13 @@ async function main() {
36
36
  r = await flowFetch('/api/flow/claim', { method: 'POST', body });
37
37
  }
38
38
  if (!r?.task?.id) throw new Error('Server returned no task. Check your connection and try again.');
39
- process.stdout.write(`Claimed #${r.task.issue_num ?? r.task.id.slice(0, 6)} — "${r.task.title}"\n`);
39
+ process.stdout.write(`Claimed #${r.task.issue_num ?? r.task.id.slice(0, 6)} — "${sanitizeText(r.task.title, 80)}"\n`);
40
40
 
41
41
  // Warn on file conflicts
42
42
  if (r.conflict_warnings?.length) {
43
43
  process.stdout.write(`\n⚠ File conflict${r.conflict_warnings.length === 1 ? '' : 's'} detected:\n`);
44
44
  for (const w of r.conflict_warnings) {
45
- process.stdout.write(` #${w.task_ref} "${w.title}" (${w.claimed_by || 'unassigned'})\n`);
45
+ process.stdout.write(` #${w.task_ref} "${sanitizeText(w.title, 60)}" (${w.claimed_by || 'unassigned'})\n`);
46
46
  process.stdout.write(` overlapping files: ${w.overlapping_files.join(', ')}\n`);
47
47
  }
48
48
  process.stdout.write('Consider coordinating before editing these files.\n\n');
package/bin/close.mjs CHANGED
@@ -1,15 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
  /* flow:close — mark a task done.
3
- Usage: flow-close <task_id> [--summary="..."]
3
+ Usage: flow-close <task_id> "<summary>" (--summary="..." also accepted)
4
4
  */
5
5
 
6
- import { flowFetch, resolveTaskId, positional, arg, die } from './_client.mjs';
6
+ import { flowFetch, resolveTaskId, positional, arg, die, sanitizeText } from './_client.mjs';
7
7
 
8
8
  async function main() {
9
9
  const raw = positional(0);
10
- if (!raw) die('Usage: flow-close <task_id> [--summary="..."]');
10
+ if (!raw) die('Usage: flow-close <task_id> "<summary>"');
11
11
  const id = await resolveTaskId(raw);
12
- const summary = arg('summary');
12
+ const summary = positional(1) || arg('summary');
13
13
 
14
14
  const r = await flowFetch('/api/flow/close', {
15
15
  method: 'POST',
@@ -17,7 +17,7 @@ async function main() {
17
17
  });
18
18
 
19
19
  const ref = r.task.issue_num != null ? r.task.issue_num : r.task.id.slice(0, 6);
20
- process.stdout.write(`Closed #${ref} — "${r.task.title}"\n`);
20
+ process.stdout.write(`Closed #${ref} — "${sanitizeText(r.task.title, 80)}"\n`);
21
21
  }
22
22
 
23
23
  main().catch(e => die(e.message || e));
package/bin/create.mjs CHANGED
@@ -21,7 +21,7 @@
21
21
  */
22
22
 
23
23
  import 'dotenv/config';
24
- import { flowFetch, resolveTaskId, arg, die } from './_client.mjs';
24
+ import { flowFetch, resolveTaskId, arg, die, sanitizeText } from './_client.mjs';
25
25
  import { githubHeaders, fetchProjectItem, labelsToType } from './_github.mjs';
26
26
 
27
27
  async function fetchGithubIssue(issueNum) {
@@ -113,7 +113,7 @@ async function main() {
113
113
  const r = await flowFetch('/api/flow/tasks', { method: 'POST', body });
114
114
  const t = r.task || r;
115
115
  if (!t?.id) throw new Error('Server returned no task. Check your connection and try again.');
116
- process.stdout.write(`Created #${t.id.slice(0, 6)} — "${t.title}"\n`);
116
+ process.stdout.write(`Created #${t.id.slice(0, 6)} — "${sanitizeText(t.title, 80)}"\n`);
117
117
  process.stdout.write(` id: ${t.id}\n`);
118
118
  process.stdout.write(` status: ${t.status}\n`);
119
119
  process.stdout.write(` priority: ${t.priority} type: ${t.type} area: ${t.area}\n`);
package/bin/edit.mjs CHANGED
@@ -8,7 +8,7 @@
8
8
  flow-edit <id> --blocked-by=<uuid>
9
9
  */
10
10
 
11
- import { flowFetch, resolveTaskId, positional, arg, die } from './_client.mjs';
11
+ import { flowFetch, resolveTaskId, positional, arg, die, sanitizeText } from './_client.mjs';
12
12
 
13
13
  // B16: the server enum is P0-now / P1-soon / P2-later. Accept p0/p1/p2 (and any case of the
14
14
  // canonical form) so the documented shorthand isn't rejected; pass anything else through for
@@ -47,7 +47,7 @@ async function main() {
47
47
  const res = await flowFetch(`/api/flow/tasks/${id}`, { method: 'PATCH', body: changes });
48
48
 
49
49
  const updated = res.task || res;
50
- const label = updated.title || id.slice(0, 8);
50
+ const label = sanitizeText(updated.title, 80) || id.slice(0, 8);
51
51
  process.stdout.write(`Updated: ${label}\n`);
52
52
  for (const [k, v] of Object.entries(changes)) {
53
53
  const display = v === null || (Array.isArray(v) && v.length === 0) ? '(cleared)' : v;
package/bin/log.mjs CHANGED
@@ -6,17 +6,7 @@
6
6
  flow-log --limit=50 # more events
7
7
  */
8
8
 
9
- import { flowFetch, resolveTaskId, arg, die } from './_client.mjs';
10
-
11
- function sanitize(s) {
12
- if (!s || typeof s !== 'string') return '';
13
- return s
14
- .replace(/\x1b\[[0-9;]*[A-Za-z]/g, '')
15
- .replace(/@to:\S+/g, '[mention]')
16
- .replace(/\r?\n|\r/g, ' ')
17
- .trim()
18
- .slice(0, 120);
19
- }
9
+ import { flowFetch, resolveTaskId, arg, die, sanitizeText } from './_client.mjs';
20
10
 
21
11
  function fmt(ts) {
22
12
  if (!ts) return ' ';
@@ -29,8 +19,10 @@ function pad(s, n) { return String(s ?? '').padEnd(n).slice(0, n); }
29
19
 
30
20
  function fmtEvent(e) {
31
21
  const p = e.payload || {};
32
- if (p.comment) return sanitize(p.comment);
33
- if (p.act) return `[${p.act}${p.summary ? ': ' + sanitize(p.summary, 80) : ''}]`;
22
+ // Wrap untrusted comment bodies in the fence marker (sanitize BEFORE wrapping so a
23
+ // crafted comment can't inject a closing tag) matches pull.mjs.
24
+ if (p.comment) return `<comment_from_untrusted_user>${sanitizeText(p.comment)}</comment_from_untrusted_user>`;
25
+ if (p.act) return `[${p.act}${p.summary ? ': ' + sanitizeText(p.summary, 80) : ''}]`;
34
26
  return `[${e.kind || 'event'}]`;
35
27
  }
36
28
 
@@ -53,7 +45,7 @@ async function main() {
53
45
 
54
46
  if (events.length === 0) { process.stdout.write('No timeline events.\n'); return; }
55
47
 
56
- process.stdout.write(`Timeline for #${task.issue_num ?? id.slice(0, 6)}: ${task.title}\n\n`);
48
+ process.stdout.write(`Timeline for #${task.issue_num ?? id.slice(0, 6)}: ${sanitizeText(task.title, 120)}\n\n`);
57
49
  for (const e of events) {
58
50
  const who = e.acting_as_id ? `${e.acting_as_id}(${e.actor_id})` : (e.actor_id || '?');
59
51
  process.stdout.write(` ${fmt(e.ts)} ${pad(who, 18)} ${fmtEvent(e)}\n`);
package/bin/project.mjs CHANGED
@@ -10,7 +10,7 @@
10
10
  */
11
11
 
12
12
  import 'dotenv/config';
13
- import { die } from './_client.mjs';
13
+ import { die, sanitizeText } from './_client.mjs';
14
14
  import { fetchProjectItems, contentKind } from './_github.mjs';
15
15
 
16
16
  async function main() {
@@ -30,7 +30,7 @@ async function main() {
30
30
 
31
31
  const { title, items } = await fetchProjectItems(projectId);
32
32
 
33
- process.stdout.write(`Project: ${title}\n`);
33
+ process.stdout.write(`Project: ${sanitizeText(title, 100)}\n`);
34
34
  process.stdout.write(` ${items.length} item${items.length !== 1 ? 's' : ''}\n\n`);
35
35
 
36
36
  if (!items.length) {
@@ -41,7 +41,7 @@ async function main() {
41
41
  for (const { id, content } of items) {
42
42
  const kind = contentKind(content);
43
43
  const num = content.number ? ` #${content.number}` : '';
44
- process.stdout.write(` [${id}] ${content.title}${num ? ` (${kind}${num})` : ` (${kind})`}\n`);
44
+ process.stdout.write(` [${id}] ${sanitizeText(content.title, 80)}${num ? ` (${kind}${num})` : ` (${kind})`}\n`);
45
45
  }
46
46
 
47
47
  process.stdout.write(`\nTo import an item:\n flow-create --from-project-item=<id above>\n`);
package/bin/propose.mjs CHANGED
@@ -19,7 +19,7 @@
19
19
  - Dedup on title (HTTP 409)
20
20
  */
21
21
 
22
- import { flowFetch, resolveTaskId, arg, die } from './_client.mjs';
22
+ import { flowFetch, resolveTaskId, arg, die, sanitizeText } from './_client.mjs';
23
23
 
24
24
  async function main() {
25
25
  const parent = arg('parent') ? await resolveTaskId(arg('parent')) : null;
@@ -42,7 +42,7 @@ async function main() {
42
42
  try {
43
43
  const r = await flowFetch('/api/flow/propose', { method: 'POST', body });
44
44
  process.stdout.write(`Proposal accepted: #${r.decision.id.slice(0, 6)}\n`);
45
- process.stdout.write(` Title: ${r.decision.proposal_title}\n`);
45
+ process.stdout.write(` Title: ${sanitizeText(r.decision.proposal_title, 100)}\n`);
46
46
  process.stdout.write(` Suggested assignee: ${r.decision.suggested_assignee_id || '-'}\n`);
47
47
  process.stdout.write(` Source refs: ${(r.decision.source_refs || []).join(', ') || '-'}\n`);
48
48
  process.stdout.write('\nThe user will see this in the "Awaiting Direction" column.\n');
package/bin/pull.mjs CHANGED
@@ -45,14 +45,14 @@ const PRIO_RANK = { 'P0-now': 0, 'P1-soon': 1, 'P2-later': 2 };
45
45
  const STATUS_RANK = { in_progress: 0, in_review: 1, todo: 2, backlog: 3, awaiting_direction: 4 };
46
46
 
47
47
  function fmtTask(t) {
48
- const miTag = t.milestone ? ` [${t.milestone}]` : '';
48
+ const miTag = t.milestone ? ` [${sanitizeText(t.milestone, 40)}]` : '';
49
49
  const prTag = t.pr_num ? ` [PR #${t.pr_num}: ${t.pr_state || 'open'}]` : '';
50
50
  const parts = [
51
51
  pad(`#${t.issue_num ?? t.id.slice(0, 6)}`, 8),
52
52
  pad(t.status, 12),
53
53
  pad(t.priority || '-', 9),
54
54
  pad(t.assignee_id || '(unassigned)', 14),
55
- t.title + miTag + prTag,
55
+ sanitizeText(t.title, 100) + miTag + prTag,
56
56
  ];
57
57
  return parts.join(' ');
58
58
  }
package/bin/reject.mjs CHANGED
@@ -1,15 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
  /* flow:reject — reject a pending decision.
3
- Usage: flow-reject <decision_id> [--reason="..."]
3
+ Usage: flow-reject <decision_id> "<reason>" (--reason="..." also accepted)
4
4
  */
5
5
 
6
6
  import { flowFetch, resolveTaskId, positional, arg, die } from './_client.mjs';
7
7
 
8
8
  async function main() {
9
9
  const raw = positional(0);
10
- if (!raw) die('Usage: flow-reject <decision_id> [--reason="..."]');
10
+ if (!raw) die('Usage: flow-reject <decision_id> "<reason>"');
11
11
  const id = await resolveTaskId(raw);
12
- const reason = arg('reason');
12
+ const reason = positional(1) || arg('reason');
13
13
  await flowFetch(`/api/flow/decisions/${id}/reject`, {
14
14
  method: 'POST', body: reason ? { reason } : {},
15
15
  });
package/bin/scan.mjs CHANGED
@@ -10,7 +10,7 @@
10
10
  */
11
11
 
12
12
  import 'dotenv/config';
13
- import { flowFetch, arg, die } from './_client.mjs';
13
+ import { flowFetch, arg, die, sanitizeText } from './_client.mjs';
14
14
  import { githubHeaders } from './_github.mjs';
15
15
  import { readFileSync, readdirSync } from 'node:fs';
16
16
  import { join, extname, relative } from 'node:path';
@@ -123,7 +123,7 @@ async function scanIssues(repo) {
123
123
  const labels = (i.labels || []).map(l => l.name.toLowerCase());
124
124
  const type = labels.includes('bug') ? 'bug' : labels.includes('documentation') ? 'docs' : 'feature';
125
125
  return {
126
- label: `GitHub Issue #${i.number}: ${i.title}`,
126
+ label: `GitHub Issue #${i.number}: ${sanitizeText(i.title, 80)}`,
127
127
  detail: ` ${i.html_url}`,
128
128
  cmd: `flow-create --from-issue=${i.number} --type=${type}`,
129
129
  };
@@ -150,9 +150,9 @@ async function scanPRs(repo) {
150
150
  const trackedPRs = new Set(allTasks.filter(t => t.pr_num).map(t => t.pr_num));
151
151
  const unlinked = ghPRs.filter(pr => !trackedPRs.has(pr.number));
152
152
  return unlinked.slice(0, 10).map(pr => {
153
- const title = pr.title.replace(/"/g, '\\"').slice(0, 55);
153
+ const title = sanitizeText(pr.title, 55).replace(/"/g, '\\"');
154
154
  return {
155
- label: `Unlinked PR #${pr.number}: ${pr.title}`,
155
+ label: `Unlinked PR #${pr.number}: ${sanitizeText(pr.title, 80)}`,
156
156
  detail: ` ${pr.html_url} (${pr.draft ? 'draft' : 'open'})`,
157
157
  cmd: `flow-create --title="Track PR #${pr.number}: ${title}" --type=chore --area=backend`,
158
158
  };
package/bin/search.mjs CHANGED
@@ -6,7 +6,7 @@
6
6
  flow-search "ui" --area=frontend --assignee=nate
7
7
  */
8
8
 
9
- import { flowFetch, arg, positional } from './_client.mjs';
9
+ import { flowFetch, arg, positional, sanitizeText } from './_client.mjs';
10
10
 
11
11
  function pad(s, n) { return String(s).padEnd(n).slice(0, n); }
12
12
 
@@ -40,7 +40,7 @@ async function main() {
40
40
  const ref = `#${t.issue_num ?? t.id.slice(0, 6)}`;
41
41
  const due = t.due_at ? ` due:${t.due_at.slice(0, 10)}` : '';
42
42
  process.stdout.write(
43
- `${pad(ref, 8)} ${pad(t.status, 12)} ${pad(t.priority || '-', 9)} ${pad(t.assignee_id || '-', 14)} ${t.title}${due}\n`
43
+ `${pad(ref, 8)} ${pad(t.status, 12)} ${pad(t.priority || '-', 9)} ${pad(t.assignee_id || '-', 14)} ${sanitizeText(t.title, 60)}${due}\n`
44
44
  );
45
45
  }
46
46
  }
package/bin/status.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  Usage: flow-status
4
4
  */
5
5
 
6
- import { flowFetch } from './_client.mjs';
6
+ import { flowFetch, sanitizeText } from './_client.mjs';
7
7
 
8
8
  function pad(s, n) { return String(s).padEnd(n).slice(0, n); }
9
9
 
@@ -53,7 +53,7 @@ async function main() {
53
53
  for (const t of arr) {
54
54
  const stale = t.updated_at && (now - new Date(t.updated_at).getTime()) > STALE_HOURS * 3600000;
55
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}`);
56
+ out.push(` #${t.issue_num ?? t.id.slice(0, 6)} ${pad(t.assignee_id || 'unassigned', 14)} ${sanitizeText(t.title, 50)}${staleMark}`);
57
57
  }
58
58
  }
59
59
  }
@@ -74,7 +74,7 @@ async function main() {
74
74
  const heartbeatTask = a.task_id ? taskById.get(a.task_id) : null;
75
75
  const displayTask = heartbeatTask || myTasks[0];
76
76
  const taskPart = displayTask
77
- ? `#${displayTask.issue_num ?? displayTask.id.slice(0, 6)} "${displayTask.title.slice(0, 38)}"`
77
+ ? `#${displayTask.issue_num ?? displayTask.id.slice(0, 6)} "${sanitizeText(displayTask.title, 38)}"`
78
78
  : 'idle';
79
79
  const extraCount = myTasks.length > 1 ? ` +${myTasks.length - 1} more` : '';
80
80
  out.push(` ${pad(a.actor_id, 16)} ${taskPart}${extraCount} (${relAgo(a.last_seen_at)})`);
@@ -91,7 +91,7 @@ async function main() {
91
91
  out.push(`[decisions] ${decisions.length} pending`);
92
92
  if (decisions.length) {
93
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)}`);
94
+ out.push(` #${d.id.slice(0, 6)} [${d.confidence}] ${sanitizeText(d.proposal_title, 65)}`);
95
95
  }
96
96
  if (decisions.length > 5) out.push(` … and ${decisions.length - 5} more`);
97
97
  }
package/bin/unblock.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  Usage: flow-unblock <task_id>
4
4
  */
5
5
 
6
- import { flowFetch, resolveTaskId, positional, die } from './_client.mjs';
6
+ import { flowFetch, resolveTaskId, positional, die, sanitizeText } from './_client.mjs';
7
7
 
8
8
  async function main() {
9
9
  const raw = positional(0);
@@ -12,7 +12,7 @@ async function main() {
12
12
  const id = await resolveTaskId(raw);
13
13
  const res = await flowFetch(`/api/flow/tasks/${id}`, { method: 'PATCH', body: { blocked_by: [] } });
14
14
 
15
- const title = res.task?.title || id.slice(0, 8);
15
+ const title = sanitizeText(res.task?.title, 80) || id.slice(0, 8);
16
16
  process.stdout.write(`Unblocked: ${title}\n`);
17
17
  }
18
18
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowcollab",
3
- "version": "0.3.19",
3
+ "version": "0.3.20",
4
4
  "description": "Multi-Claude coordination layer — shared task board + CLI for teams running Claude Code",
5
5
  "type": "module",
6
6
  "files": [