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.
- package/LICENSE +6 -0
- package/bin/_client.mjs +97 -0
- package/bin/_github.mjs +98 -0
- package/bin/approve.mjs +27 -0
- package/bin/assign.mjs +36 -0
- package/bin/claim.mjs +63 -0
- package/bin/close-sprint.mjs +61 -0
- package/bin/close.mjs +22 -0
- package/bin/comment.mjs +17 -0
- package/bin/completion.mjs +112 -0
- package/bin/create.mjs +116 -0
- package/bin/decisions.mjs +33 -0
- package/bin/edit.mjs +45 -0
- package/bin/handoff.mjs +57 -0
- package/bin/heartbeat.mjs +22 -0
- package/bin/init.mjs +187 -0
- package/bin/log.mjs +79 -0
- package/bin/login.mjs +80 -0
- package/bin/ping.mjs +48 -0
- package/bin/pr.mjs +24 -0
- package/bin/project.mjs +50 -0
- package/bin/propose.mjs +57 -0
- package/bin/pull.mjs +392 -0
- package/bin/reject.mjs +19 -0
- package/bin/review.mjs +85 -0
- package/bin/search.mjs +48 -0
- package/bin/standup.mjs +136 -0
- package/bin/status.mjs +102 -0
- package/bin/sync.mjs +104 -0
- package/bin/unblock.mjs +19 -0
- package/bin/whoami.mjs +39 -0
- package/package.json +61 -0
package/bin/propose.mjs
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* flow:propose — Claude submits a successor decision.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
flow-propose \
|
|
6
|
+
--parent=<task_id> \
|
|
7
|
+
--title="..." \
|
|
8
|
+
--md="..." \
|
|
9
|
+
--assignee=<actor_id> \
|
|
10
|
+
--priority=P0-now|P1-soon|P2-later \
|
|
11
|
+
--type=bug|feature|chore|docs \
|
|
12
|
+
--area=<area_label> \
|
|
13
|
+
--refs="doc#section,doc2#sec" \
|
|
14
|
+
--confidence=low|medium|high
|
|
15
|
+
|
|
16
|
+
Server enforces:
|
|
17
|
+
- No hardbaked-example patterns (HTTP 422 on match)
|
|
18
|
+
- Hard cap 3 pending per parent (HTTP 429)
|
|
19
|
+
- Dedup on title (HTTP 409)
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { flowFetch, resolveTaskId, arg, die } from './_client.mjs';
|
|
23
|
+
|
|
24
|
+
async function main() {
|
|
25
|
+
const parent = arg('parent') ? await resolveTaskId(arg('parent')) : null;
|
|
26
|
+
const title = arg('title');
|
|
27
|
+
const md = arg('md');
|
|
28
|
+
if (!title || !md) die('Required: --title="..." --md="..."');
|
|
29
|
+
|
|
30
|
+
const body = {
|
|
31
|
+
parent_task_id: parent,
|
|
32
|
+
proposal_title: title,
|
|
33
|
+
proposal_md: md,
|
|
34
|
+
suggested_assignee_id: arg('assignee') || null,
|
|
35
|
+
suggested_priority: arg('priority') || null,
|
|
36
|
+
suggested_type: arg('type') || null,
|
|
37
|
+
suggested_area: arg('area') || null,
|
|
38
|
+
source_refs: (arg('refs') || '').split(',').map(s => s.trim()).filter(Boolean),
|
|
39
|
+
confidence: arg('confidence') || 'medium',
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const r = await flowFetch('/api/flow/propose', { method: 'POST', body });
|
|
44
|
+
process.stdout.write(`Proposal accepted: #${r.decision.id.slice(0, 6)}\n`);
|
|
45
|
+
process.stdout.write(` Title: ${r.decision.proposal_title}\n`);
|
|
46
|
+
process.stdout.write(` Suggested assignee: ${r.decision.suggested_assignee_id || '-'}\n`);
|
|
47
|
+
process.stdout.write(` Source refs: ${(r.decision.source_refs || []).join(', ') || '-'}\n`);
|
|
48
|
+
process.stdout.write('\nThe user will see this in the "Awaiting Direction" column.\n');
|
|
49
|
+
} catch (e) {
|
|
50
|
+
if (e.status === 422) die(`Rejected — hardbaked pattern in proposal. ${e.body?.detail || ''}`);
|
|
51
|
+
if (e.status === 429) die('Rejected — hard cap of 3 pending decisions per parent already reached.');
|
|
52
|
+
if (e.status === 409) die('Rejected — duplicate of an existing pending decision.');
|
|
53
|
+
die(e.message || e);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
main().catch(e => die(e.message || e));
|
package/bin/pull.mjs
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* flow:pull — fetch tasks + decisions + recent timeline and print a Claude-ready summary.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
flow-pull # respects FLOW_DEFAULT_ASSIGNEE env
|
|
6
|
+
flow-pull --assignee=nate # explicit filter
|
|
7
|
+
flow-pull --assignee=all # unfiltered (full board)
|
|
8
|
+
|
|
9
|
+
The CLI surfaces three things Claude must scan on every pull:
|
|
10
|
+
1. *** MENTIONS *** banner: timeline comments addressed to you
|
|
11
|
+
2. Filtered board snapshot (your tasks only by default)
|
|
12
|
+
3. Recent timeline activity on YOUR tasks (last 5 per task)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { flowFetch, arg } from './_client.mjs';
|
|
16
|
+
import { existsSync, writeFileSync } from 'fs';
|
|
17
|
+
import { join } from 'path';
|
|
18
|
+
|
|
19
|
+
// Per-status age threshold (days) before pull flags a task as aging.
|
|
20
|
+
// Override with FLOW_AGING_THRESHOLDS={"in_progress":3,"in_review":2} env var.
|
|
21
|
+
const AGING = (() => {
|
|
22
|
+
try { return process.env.FLOW_AGING_THRESHOLDS ? JSON.parse(process.env.FLOW_AGING_THRESHOLDS) : null; }
|
|
23
|
+
catch { return null; }
|
|
24
|
+
})() || { in_progress: 3, in_review: 2, todo: 7 };
|
|
25
|
+
|
|
26
|
+
// Sanitize user-controlled text before printing to stdout.
|
|
27
|
+
// Defends against prompt injection: a comment containing newlines, ANSI codes,
|
|
28
|
+
// or @to: tokens can inject fake structured sections into pull output that
|
|
29
|
+
// another Claude reads as trusted shell output.
|
|
30
|
+
function sanitizeText(s, maxLen = 400) {
|
|
31
|
+
if (!s || typeof s !== 'string') return '';
|
|
32
|
+
return s
|
|
33
|
+
.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '') // strip ANSI escape sequences
|
|
34
|
+
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '') // strip control chars (keep \t \n)
|
|
35
|
+
.replace(/@to:\S+/g, '[mention-stripped]') // prevent nested @to: injection
|
|
36
|
+
.replace(/\r?\n|\r/g, ' ↵ ') // collapse newlines to visible marker
|
|
37
|
+
.trim()
|
|
38
|
+
.slice(0, maxLen);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function pad(s, n) { return String(s).padEnd(n).slice(0, n); }
|
|
42
|
+
|
|
43
|
+
const PRIO_RANK = { 'P0-now': 0, 'P1-soon': 1, 'P2-later': 2 };
|
|
44
|
+
const STATUS_RANK = { in_progress: 0, in_review: 1, todo: 2, backlog: 3, awaiting_direction: 4 };
|
|
45
|
+
|
|
46
|
+
function fmtTask(t) {
|
|
47
|
+
const miTag = t.milestone ? ` [${t.milestone}]` : '';
|
|
48
|
+
const prTag = t.pr_num ? ` [PR #${t.pr_num}: ${t.pr_state || 'open'}]` : '';
|
|
49
|
+
const parts = [
|
|
50
|
+
pad(`#${t.issue_num ?? t.id.slice(0, 6)}`, 8),
|
|
51
|
+
pad(t.status, 12),
|
|
52
|
+
pad(t.priority || '-', 9),
|
|
53
|
+
pad(t.assignee_id || '(unassigned)', 14),
|
|
54
|
+
t.title + miTag + prTag,
|
|
55
|
+
];
|
|
56
|
+
return parts.join(' ');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function fmtDecision(d) {
|
|
60
|
+
return `#${d.id.slice(0, 6)} [${d.confidence}] ${sanitizeText(d.proposal_title, 120)} (suggest: ${d.suggested_assignee_id || '-'}/${d.suggested_priority || '-'}) refs: ${(d.source_refs || []).join(', ') || '-'}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function extractBody(ev) {
|
|
64
|
+
const p = ev.payload || {};
|
|
65
|
+
if (p.comment) return sanitizeText(p.comment);
|
|
66
|
+
if (ev.kind === 'event') {
|
|
67
|
+
const act = p.act;
|
|
68
|
+
if (act === 'claimed') return '[claimed]';
|
|
69
|
+
if (act === 'closed') return `[closed${p.summary ? ': ' + sanitizeText(p.summary, 200) : ''}]`;
|
|
70
|
+
if (act === 'assigned') return `[assigned → ${p.assignee_id || 'unassigned'}]`;
|
|
71
|
+
if (act === 'promoted_from_decision') return '[promoted from awaiting-direction]';
|
|
72
|
+
return `[${act || 'event'}]`;
|
|
73
|
+
}
|
|
74
|
+
return `[${ev.kind}]`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function fmtTimeline(ev, taskTitle) {
|
|
78
|
+
const ts = ev.ts ? new Date(ev.ts).toISOString().replace('T', ' ').slice(0, 16) : '?';
|
|
79
|
+
const who = ev.actor_id || '?';
|
|
80
|
+
const actingAs = ev.acting_as_id && ev.acting_as_id !== ev.actor_id ? `(as ${ev.acting_as_id})` : '';
|
|
81
|
+
const body = extractBody(ev);
|
|
82
|
+
// Wrap user-generated comments in XML markers so Claude distinguishes them
|
|
83
|
+
// from structured pull output and cannot be injected via timeline text.
|
|
84
|
+
const wrapped = ev.kind === 'comment'
|
|
85
|
+
? `<comment_from_untrusted_user>${body}</comment_from_untrusted_user>`
|
|
86
|
+
: body;
|
|
87
|
+
return ` ${ts} ${who}${actingAs} on "${sanitizeText(taskTitle, 120)}": ${wrapped}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function mentionRegex(assignee) {
|
|
91
|
+
const a = assignee.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
92
|
+
return new RegExp(`@to:(?:${a}-claude|${a}|claude)(?=[\\s.,;:!?)\\]]|$)`, 'i');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function main() {
|
|
96
|
+
const explicit = arg('assignee');
|
|
97
|
+
const envDefault = process.env.FLOW_DEFAULT_ASSIGNEE || '';
|
|
98
|
+
const filterAssignee = explicit === 'all' ? '' : (explicit || envDefault);
|
|
99
|
+
const focusMode = arg('focus') !== undefined;
|
|
100
|
+
const filterMilestone = arg('milestone') || '';
|
|
101
|
+
|
|
102
|
+
const [tasksRes, decRes, presenceRes] = await Promise.all([
|
|
103
|
+
flowFetch('/api/flow/tasks?limit=200'),
|
|
104
|
+
flowFetch('/api/flow/decisions'),
|
|
105
|
+
flowFetch('/api/flow/presence').catch(() => null),
|
|
106
|
+
]);
|
|
107
|
+
|
|
108
|
+
const allTasks = tasksRes.tasks || [];
|
|
109
|
+
const allTimeline = tasksRes.timeline || [];
|
|
110
|
+
if (tasksRes.has_more) {
|
|
111
|
+
process.stderr.write(`⚠ Board has more than 200 tasks — snapshot truncated to most recently updated 200.\n`);
|
|
112
|
+
}
|
|
113
|
+
const taskById = new Map(allTasks.map(t => [t.id, t]));
|
|
114
|
+
|
|
115
|
+
let tasks = allTasks;
|
|
116
|
+
if (filterAssignee) tasks = tasks.filter(t => t.assignee_id === filterAssignee);
|
|
117
|
+
if (filterMilestone) tasks = tasks.filter(t => t.milestone === filterMilestone);
|
|
118
|
+
const myTaskIds = new Set(tasks.map(t => t.id));
|
|
119
|
+
|
|
120
|
+
const out = [];
|
|
121
|
+
|
|
122
|
+
// 0. Active agents — other agents heartbeating right now
|
|
123
|
+
if (presenceRes?.agents?.length) {
|
|
124
|
+
const myActorId = process.env.FLOW_TOKEN_OWNER_ACTOR || process.env.FLOW_TOKEN_CONTRIBUTOR_ACTOR || '';
|
|
125
|
+
const others = presenceRes.agents.filter(a => a.actor_id !== myActorId);
|
|
126
|
+
if (others.length) {
|
|
127
|
+
out.push(`[active agents — ${others.length} other${others.length === 1 ? '' : 's'} online]`);
|
|
128
|
+
for (const a of others) {
|
|
129
|
+
const task = a.task_id ? taskById.get(a.task_id) : null;
|
|
130
|
+
const taskRef = task
|
|
131
|
+
? `working on #${task.issue_num ?? a.task_id.slice(0, 6)} "${sanitizeText(task.title, 60)}"`
|
|
132
|
+
: 'idle';
|
|
133
|
+
const ago = Math.round((Date.now() - new Date(a.last_seen_at).getTime()) / 60000);
|
|
134
|
+
out.push(` ${a.actor_id} ${taskRef} (seen ${ago}m ago)`);
|
|
135
|
+
}
|
|
136
|
+
out.push('');
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// 1. Mentions banner — scan ALL timeline for @to:me
|
|
141
|
+
if (filterAssignee) {
|
|
142
|
+
const re = mentionRegex(filterAssignee);
|
|
143
|
+
const mentions = allTimeline
|
|
144
|
+
.filter(ev => re.test(extractBody(ev)))
|
|
145
|
+
.sort((a, b) => (b.ts || '').localeCompare(a.ts || ''));
|
|
146
|
+
if (mentions.length) {
|
|
147
|
+
out.push('*** MENTIONS *** ' + mentions.length + ' comment' + (mentions.length === 1 ? '' : 's') + ' addressed to you (@to:' + filterAssignee + ' / -claude / claude):');
|
|
148
|
+
mentions.slice(0, 10).forEach(ev => {
|
|
149
|
+
const t = taskById.get(ev.task_id);
|
|
150
|
+
const title = t ? t.title : '(unknown task)';
|
|
151
|
+
const taskRef = t ? `#${t.issue_num ?? t.id.slice(0, 6)}` : `#${ev.task_id.slice(0, 6)}`;
|
|
152
|
+
const ts = ev.ts ? new Date(ev.ts).toISOString().replace('T', ' ').slice(0, 16) : '?';
|
|
153
|
+
out.push(` ${taskRef} ${title} (${ts}, from ${ev.actor_id || '?'})`);
|
|
154
|
+
out.push(` └─ <comment_from_untrusted_user>${extractBody(ev)}</comment_from_untrusted_user>`);
|
|
155
|
+
});
|
|
156
|
+
if (mentions.length > 10) out.push(` … and ${mentions.length - 10} older mention${mentions.length - 10 === 1 ? '' : 's'} not shown`);
|
|
157
|
+
out.push('');
|
|
158
|
+
out.push('Respond to mentions before claiming new work.');
|
|
159
|
+
out.push('');
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// 1b. Overdue tasks — past due_at and not done
|
|
164
|
+
const nowDate = new Date();
|
|
165
|
+
const overdueTasks = allTasks
|
|
166
|
+
.filter(t => t.due_at && new Date(t.due_at) < nowDate && t.status !== 'done')
|
|
167
|
+
.sort((a, b) => new Date(a.due_at) - new Date(b.due_at));
|
|
168
|
+
if (overdueTasks.length) {
|
|
169
|
+
out.push(`⚠ OVERDUE — ${overdueTasks.length} task${overdueTasks.length === 1 ? '' : 's'} past due date:`);
|
|
170
|
+
for (const t of overdueTasks) {
|
|
171
|
+
const daysAgo = Math.round((nowDate - new Date(t.due_at)) / 86400000);
|
|
172
|
+
const dueLabel = daysAgo < 1 ? 'today' : daysAgo === 1 ? '1 day ago' : `${daysAgo} days ago`;
|
|
173
|
+
out.push(` #${t.issue_num ?? t.id.slice(0, 6)} ${sanitizeText(t.title, 60)} (due ${dueLabel}, ${t.assignee_id || 'unassigned'})`);
|
|
174
|
+
}
|
|
175
|
+
out.push('');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// 1c. Stale claim detection — in_progress tasks with no activity for >4h
|
|
179
|
+
const STALE_HOURS = 4;
|
|
180
|
+
const staleMs = STALE_HOURS * 60 * 60 * 1000;
|
|
181
|
+
const nowMs = Date.now();
|
|
182
|
+
const staleTasks = allTasks.filter(t =>
|
|
183
|
+
t.status === 'in_progress' && t.updated_at &&
|
|
184
|
+
(nowMs - new Date(t.updated_at).getTime()) > staleMs
|
|
185
|
+
);
|
|
186
|
+
if (staleTasks.length) {
|
|
187
|
+
out.push(`⚠ STALE CLAIMS — ${staleTasks.length} task${staleTasks.length === 1 ? '' : 's'} in_progress with no activity in ${STALE_HOURS}h+:`);
|
|
188
|
+
for (const t of staleTasks) {
|
|
189
|
+
const hrs = Math.round((nowMs - new Date(t.updated_at).getTime()) / 3600000);
|
|
190
|
+
out.push(` #${t.issue_num ?? t.id.slice(0, 6)} ${sanitizeText(t.title, 60)} (${t.assignee_id || 'unassigned'}, idle ${hrs}h)`);
|
|
191
|
+
}
|
|
192
|
+
out.push(' Force-unclaim via the board UI (owner only).');
|
|
193
|
+
out.push('');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// 1c. Handoffs addressed to me
|
|
197
|
+
if (filterAssignee) {
|
|
198
|
+
const myHandoffs = allTimeline
|
|
199
|
+
.filter(ev => ev.kind === 'event' && ev.payload?.act === 'handoff' && ev.payload?.to === filterAssignee)
|
|
200
|
+
.sort((a, b) => (b.ts || '').localeCompare(a.ts || ''))
|
|
201
|
+
.slice(0, 5);
|
|
202
|
+
if (myHandoffs.length) {
|
|
203
|
+
out.push(`*** HANDOFFS *** ${myHandoffs.length} task${myHandoffs.length === 1 ? '' : 's'} handed off to you:`);
|
|
204
|
+
for (const ev of myHandoffs) {
|
|
205
|
+
const t = taskById.get(ev.task_id);
|
|
206
|
+
const ref = t ? `#${t.issue_num ?? t.id.slice(0, 6)}` : `#${ev.task_id.slice(0, 6)}`;
|
|
207
|
+
const title = t ? sanitizeText(t.title, 60) : '(unknown task)';
|
|
208
|
+
const ts = ev.ts ? new Date(ev.ts).toISOString().replace('T', ' ').slice(0, 16) : '?';
|
|
209
|
+
out.push(` ${ref} ${title} (from ${ev.actor_id || '?'}, ${ts})`);
|
|
210
|
+
const hd = t?.handoff_data;
|
|
211
|
+
if (hd?.context || ev.payload?.context) {
|
|
212
|
+
out.push(` context: <comment_from_untrusted_user>${sanitizeText(hd?.context || ev.payload.context, 300)}</comment_from_untrusted_user>`);
|
|
213
|
+
}
|
|
214
|
+
if (hd?.branch || ev.payload?.branch) {
|
|
215
|
+
out.push(` branch: ${sanitizeText(hd?.branch || ev.payload.branch, 100)}`);
|
|
216
|
+
}
|
|
217
|
+
if (hd?.next_step) {
|
|
218
|
+
out.push(` next step: ${sanitizeText(hd.next_step, 200)}`);
|
|
219
|
+
}
|
|
220
|
+
if (hd?.files?.length) {
|
|
221
|
+
out.push(` files: ${hd.files.slice(0, 8).join(', ')}${hd.files.length > 8 ? ` +${hd.files.length - 8} more` : ''}`);
|
|
222
|
+
}
|
|
223
|
+
if (hd?.questions?.length) {
|
|
224
|
+
out.push(` open questions:`);
|
|
225
|
+
for (const q of hd.questions.slice(0, 5)) {
|
|
226
|
+
out.push(` - <comment_from_untrusted_user>${sanitizeText(q, 200)}</comment_from_untrusted_user>`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
out.push('');
|
|
231
|
+
out.push('Claim and continue where they left off.');
|
|
232
|
+
out.push('');
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// 1e. Blocked task warnings — in_progress tasks with unresolved dependencies
|
|
237
|
+
if (filterAssignee) {
|
|
238
|
+
const blockedActive = tasks.filter(t =>
|
|
239
|
+
t.status === 'in_progress' && t.blocked_by?.length &&
|
|
240
|
+
t.blocked_by.some(bid => {
|
|
241
|
+
const b = taskById.get(bid);
|
|
242
|
+
return !b || b.status !== 'done';
|
|
243
|
+
})
|
|
244
|
+
);
|
|
245
|
+
if (blockedActive.length) {
|
|
246
|
+
out.push(`⚠ BLOCKED — ${blockedActive.length} in_progress task${blockedActive.length === 1 ? '' : 's'} with unresolved dependencies:`);
|
|
247
|
+
for (const t of blockedActive) {
|
|
248
|
+
out.push(` #${t.issue_num ?? t.id.slice(0, 6)} ${sanitizeText(t.title, 60)}`);
|
|
249
|
+
for (const bid of t.blocked_by) {
|
|
250
|
+
const b = taskById.get(bid);
|
|
251
|
+
if (!b || b.status === 'done') continue;
|
|
252
|
+
out.push(` ← blocked by: #${b.issue_num ?? b.id.slice(0, 6)} ${sanitizeText(b.title, 40)} [${b.status}]`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
out.push('');
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// 1f. Aging warnings — tasks sitting in a status longer than the threshold
|
|
260
|
+
const agingWarn = [];
|
|
261
|
+
for (const [status, days] of Object.entries(AGING)) {
|
|
262
|
+
const threshMs = days * 86400000;
|
|
263
|
+
for (const t of allTasks.filter(t => t.status === status)) {
|
|
264
|
+
if (!t.updated_at) continue;
|
|
265
|
+
const ageMs = nowMs - new Date(t.updated_at).getTime();
|
|
266
|
+
if (ageMs > threshMs) {
|
|
267
|
+
agingWarn.push({ task: t, ageDays: Math.floor(ageMs / 86400000), status });
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
if (agingWarn.length) {
|
|
272
|
+
out.push(`⚠ AGING — ${agingWarn.length} task${agingWarn.length === 1 ? '' : 's'} overdue for movement:`);
|
|
273
|
+
for (const { task: t, ageDays, status } of agingWarn) {
|
|
274
|
+
out.push(` #${t.issue_num ?? t.id.slice(0, 6)} ${sanitizeText(t.title, 60)} (${status} for ${ageDays}d, ${t.assignee_id || 'unassigned'})`);
|
|
275
|
+
}
|
|
276
|
+
out.push('');
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// 2. Board snapshot (filtered)
|
|
280
|
+
// 2a. Milestone overview (when tasks use milestones)
|
|
281
|
+
const milestoneGroups = {};
|
|
282
|
+
for (const t of allTasks) {
|
|
283
|
+
if (!t.milestone) continue;
|
|
284
|
+
if (!milestoneGroups[t.milestone]) milestoneGroups[t.milestone] = { total: 0, done: 0 };
|
|
285
|
+
milestoneGroups[t.milestone].total++;
|
|
286
|
+
if (t.status === 'done') milestoneGroups[t.milestone].done++;
|
|
287
|
+
}
|
|
288
|
+
if (Object.keys(milestoneGroups).length) {
|
|
289
|
+
out.push('[milestones]');
|
|
290
|
+
for (const [name, s] of Object.entries(milestoneGroups)) {
|
|
291
|
+
const pct = Math.round((s.done / s.total) * 100);
|
|
292
|
+
const bar = '█'.repeat(Math.round(pct / 10)) + '░'.repeat(10 - Math.round(pct / 10));
|
|
293
|
+
out.push(` ${name} ${bar} ${s.done}/${s.total} done (${pct}%)`);
|
|
294
|
+
}
|
|
295
|
+
out.push('');
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const snapshotLabel = [
|
|
299
|
+
filterAssignee ? `assignee=${filterAssignee}` : null,
|
|
300
|
+
filterMilestone ? `milestone=${filterMilestone}` : null,
|
|
301
|
+
focusMode ? 'focus' : null,
|
|
302
|
+
].filter(Boolean).join(', ') || 'unfiltered';
|
|
303
|
+
|
|
304
|
+
out.push(`=== Flow board snapshot (${snapshotLabel}) ===`);
|
|
305
|
+
|
|
306
|
+
if (focusMode) {
|
|
307
|
+
const open = tasks.filter(t => t.status !== 'done' && t.status !== 'awaiting_direction');
|
|
308
|
+
const top3 = [...open].sort((a, b) => {
|
|
309
|
+
const pr = (PRIO_RANK[a.priority] ?? 9) - (PRIO_RANK[b.priority] ?? 9);
|
|
310
|
+
if (pr !== 0) return pr;
|
|
311
|
+
return (STATUS_RANK[a.status] ?? 9) - (STATUS_RANK[b.status] ?? 9);
|
|
312
|
+
}).slice(0, 3);
|
|
313
|
+
if (top3.length) top3.forEach(t => out.push(' ' + fmtTask(t)));
|
|
314
|
+
else out.push(' (no open tasks)');
|
|
315
|
+
} else {
|
|
316
|
+
const byStatus = {
|
|
317
|
+
in_progress: tasks.filter(t => t.status === 'in_progress'),
|
|
318
|
+
in_review: tasks.filter(t => t.status === 'in_review'),
|
|
319
|
+
todo: tasks.filter(t => t.status === 'todo'),
|
|
320
|
+
backlog: tasks.filter(t => t.status === 'backlog').slice(0, 10),
|
|
321
|
+
};
|
|
322
|
+
let anyTasks = false;
|
|
323
|
+
for (const [col, arr] of Object.entries(byStatus)) {
|
|
324
|
+
if (!arr.length) continue;
|
|
325
|
+
anyTasks = true;
|
|
326
|
+
out.push(`\n[${col}] ${arr.length} task${arr.length === 1 ? '' : 's'}`);
|
|
327
|
+
arr.forEach(t => out.push(' ' + fmtTask(t)));
|
|
328
|
+
}
|
|
329
|
+
if (!anyTasks) out.push(' (no tasks)');
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// 3. Overlap watch
|
|
333
|
+
if (filterAssignee) {
|
|
334
|
+
const others = allTasks.filter(
|
|
335
|
+
t => t.status === 'in_progress' && t.assignee_id && t.assignee_id !== filterAssignee
|
|
336
|
+
);
|
|
337
|
+
if (others.length) {
|
|
338
|
+
out.push('\n[overlap watch — in-progress by others]');
|
|
339
|
+
others.forEach(t => out.push(' ' + fmtTask(t)));
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// 4. Recent timeline on YOUR tasks (last 5 per task, newest first)
|
|
344
|
+
const activeTaskIds = new Set([...myTaskIds].filter(id => {
|
|
345
|
+
const t = taskById.get(id);
|
|
346
|
+
return t && t.status !== 'closed';
|
|
347
|
+
}));
|
|
348
|
+
if (activeTaskIds.size && allTimeline.length) {
|
|
349
|
+
const recentByTask = new Map();
|
|
350
|
+
const sortedTimeline = [...allTimeline].sort((a, b) => (b.ts || '').localeCompare(a.ts || ''));
|
|
351
|
+
for (const ev of sortedTimeline) {
|
|
352
|
+
if (!activeTaskIds.has(ev.task_id)) continue;
|
|
353
|
+
if (!recentByTask.has(ev.task_id)) recentByTask.set(ev.task_id, []);
|
|
354
|
+
const arr = recentByTask.get(ev.task_id);
|
|
355
|
+
if (arr.length < 5) arr.push(ev);
|
|
356
|
+
}
|
|
357
|
+
if (recentByTask.size) {
|
|
358
|
+
out.push('\n[recent activity on your tasks]');
|
|
359
|
+
for (const [tid, evs] of recentByTask) {
|
|
360
|
+
const t = taskById.get(tid);
|
|
361
|
+
const title = t ? t.title : tid;
|
|
362
|
+
evs.forEach(ev => out.push(fmtTimeline(ev, title)));
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// 5. Decisions
|
|
368
|
+
const decisions = decRes.decisions || [];
|
|
369
|
+
out.push(`\n[awaiting direction] ${decisions.length} pending`);
|
|
370
|
+
decisions.forEach(d => out.push(' ' + fmtDecision(d)));
|
|
371
|
+
|
|
372
|
+
out.push('\nTip: claim a task with `flow-claim <task_id>`');
|
|
373
|
+
out.push(' Add --focus for top-3 by priority · --milestone=<name> to filter by sprint · --assignee=all for full board');
|
|
374
|
+
|
|
375
|
+
process.stdout.write(out.join('\n') + '\n');
|
|
376
|
+
|
|
377
|
+
// --sync-md: fetch server-rendered CLAUDE.md and write to CWD
|
|
378
|
+
if (arg('sync-md') !== undefined) {
|
|
379
|
+
try {
|
|
380
|
+
const mdRes = await flowFetch('/api/flow/claudemd');
|
|
381
|
+
const mdText = mdRes.raw || (typeof mdRes === 'string' ? mdRes : null);
|
|
382
|
+
if (!mdText) throw new Error('empty response');
|
|
383
|
+
const dest = join(process.cwd(), 'CLAUDE.md');
|
|
384
|
+
writeFileSync(dest, mdText, 'utf8');
|
|
385
|
+
process.stdout.write(`\n✓ CLAUDE.md updated (${dest})\n`);
|
|
386
|
+
} catch (e) {
|
|
387
|
+
process.stderr.write(`flow-pull: --sync-md failed: ${e.message}\n`);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
main().catch(e => { process.stderr.write('pull failed: ' + (e.message || e) + '\n'); process.exit(1); });
|
package/bin/reject.mjs
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* flow:reject — reject a pending decision.
|
|
3
|
+
Usage: flow-reject <decision_id> [--reason="..."]
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { flowFetch, resolveTaskId, positional, arg, die } from './_client.mjs';
|
|
7
|
+
|
|
8
|
+
async function main() {
|
|
9
|
+
const raw = positional(0);
|
|
10
|
+
if (!raw) die('Usage: flow-reject <decision_id> [--reason="..."]');
|
|
11
|
+
const id = await resolveTaskId(raw);
|
|
12
|
+
const reason = arg('reason');
|
|
13
|
+
await flowFetch(`/api/flow/decisions/${id}/reject`, {
|
|
14
|
+
method: 'POST', body: reason ? { reason } : {},
|
|
15
|
+
});
|
|
16
|
+
process.stdout.write('Rejected.\n');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
main().catch(e => die(e.message || e));
|
package/bin/review.mjs
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* flow:review — request a code review for a task's PR.
|
|
3
|
+
Moves the task to in_review (if in_progress) and posts a standardised
|
|
4
|
+
review-request comment that appears in the timeline and triggers @mention
|
|
5
|
+
notifications. If --reviewer is given, also pings that actor's personal
|
|
6
|
+
webhook directly (bypasses the @mention comment path).
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
flow-review <task-id> [--pr=<number>] [--reviewer=<actor-id>] [--context="notes"]
|
|
10
|
+
|
|
11
|
+
If the task already has a PR linked (pr_num set by the webhook), --pr is
|
|
12
|
+
optional. If neither is present the command aborts with a clear error.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import 'dotenv/config';
|
|
16
|
+
import { flowFetch, resolveTaskId, positional, arg, die } from './_client.mjs';
|
|
17
|
+
|
|
18
|
+
async function main() {
|
|
19
|
+
const rawId = positional(0);
|
|
20
|
+
if (!rawId) die('Usage: flow-review <task-id> [--pr=<number>] [--reviewer=<actor>] [--context="..."]');
|
|
21
|
+
|
|
22
|
+
const prArg = arg('pr');
|
|
23
|
+
const reviewerArg = arg('reviewer', '');
|
|
24
|
+
const context = arg('context', '');
|
|
25
|
+
|
|
26
|
+
const taskId = await resolveTaskId(rawId);
|
|
27
|
+
|
|
28
|
+
// Fetch tasks + people in parallel.
|
|
29
|
+
const [{ tasks = [] }, { people = {} }] = await Promise.all([
|
|
30
|
+
flowFetch('/api/flow/tasks?limit=500'),
|
|
31
|
+
flowFetch('/api/flow/people'),
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
const task = tasks.find(t => t.id === taskId);
|
|
35
|
+
if (!task) die(`Task ${taskId} not found.`);
|
|
36
|
+
|
|
37
|
+
const prNum = prArg ? parseInt(prArg, 10) : (task.pr_num ?? null);
|
|
38
|
+
if (!prNum) {
|
|
39
|
+
die(
|
|
40
|
+
`No PR linked to task #${task.issue_num ?? taskId.slice(0, 6)}.\n` +
|
|
41
|
+
'Either open a PR (the webhook will link it automatically) or pass --pr=<number>.'
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const repo = process.env.FLOW_GITHUB_REPO ?? '';
|
|
46
|
+
const prUrl = repo ? `https://github.com/${repo}/pull/${prNum}` : '';
|
|
47
|
+
|
|
48
|
+
// Compose the comment. Include @mention if reviewer specified so timeline
|
|
49
|
+
// notification routing fires for them automatically.
|
|
50
|
+
const mentionPart = reviewerArg ? `@to:${reviewerArg} ` : '';
|
|
51
|
+
const urlPart = prUrl ? `\n${prUrl}` : '';
|
|
52
|
+
const ctxPart = context ? `\n\n${context}` : '';
|
|
53
|
+
const body = `${mentionPart}Review requested: PR #${prNum}${urlPart}${ctxPart}`;
|
|
54
|
+
|
|
55
|
+
await flowFetch('/api/flow/comment', { method: 'POST', body: { task_id: taskId, body } });
|
|
56
|
+
|
|
57
|
+
// If reviewer has a personal webhook, ping it directly too (fire-and-forget).
|
|
58
|
+
if (reviewerArg) {
|
|
59
|
+
const peopleArr = Array.isArray(people) ? people : Object.values(people);
|
|
60
|
+
const reviewer = peopleArr.find(p => p.id === reviewerArg);
|
|
61
|
+
const hook = reviewer?.notify_webhook || process.env.FLOW_NOTIFY_WEBHOOK;
|
|
62
|
+
if (hook) {
|
|
63
|
+
const ref = `#${task.issue_num ?? taskId.slice(0, 6)}`;
|
|
64
|
+
fetch(hook, {
|
|
65
|
+
method: 'POST',
|
|
66
|
+
headers: { 'content-type': 'application/json' },
|
|
67
|
+
body: JSON.stringify({
|
|
68
|
+
text: `Review requested on ${ref} — PR #${prNum}${prUrl ? ' ' + prUrl : ''}${context ? '\n' + context : ''}`,
|
|
69
|
+
}),
|
|
70
|
+
}).catch(() => {});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Move to in_review if currently in_progress.
|
|
75
|
+
if (task.status === 'in_progress') {
|
|
76
|
+
await flowFetch(`/api/flow/tasks/${taskId}`, { method: 'PATCH', body: { status: 'in_review' } });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const ref = `#${task.issue_num ?? taskId.slice(0, 6)}`;
|
|
80
|
+
const moved = task.status === 'in_progress' ? ' (moved → in_review)' : '';
|
|
81
|
+
const pinged = reviewerArg ? ` (pinged ${reviewerArg})` : '';
|
|
82
|
+
process.stdout.write(`Review requested on ${ref} — PR #${prNum}${prUrl ? ' ' + prUrl : ''}${moved}${pinged}\n`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
main().catch(e => die(e.message || e));
|
package/bin/search.mjs
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* flow:search — find tasks by keyword and optional filters.
|
|
3
|
+
Usage:
|
|
4
|
+
flow-search "auth bug"
|
|
5
|
+
flow-search "token" --status=in_progress
|
|
6
|
+
flow-search "ui" --area=frontend --assignee=nate
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { flowFetch, arg, positional } from './_client.mjs';
|
|
10
|
+
|
|
11
|
+
function pad(s, n) { return String(s).padEnd(n).slice(0, n); }
|
|
12
|
+
|
|
13
|
+
async function main() {
|
|
14
|
+
const q = positional(0) || arg('q') || '';
|
|
15
|
+
const status = arg('status');
|
|
16
|
+
const area = arg('area');
|
|
17
|
+
const assignee = arg('assignee');
|
|
18
|
+
|
|
19
|
+
if (!q && !status && !area && !assignee) {
|
|
20
|
+
process.stderr.write('Usage: flow-search "query" [--status=] [--area=] [--assignee=]\n');
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const params = new URLSearchParams();
|
|
25
|
+
if (q) params.set('q', q);
|
|
26
|
+
if (status) params.set('status', status);
|
|
27
|
+
if (area) params.set('area', area);
|
|
28
|
+
if (assignee) params.set('assignee', assignee);
|
|
29
|
+
|
|
30
|
+
const r = await flowFetch(`/api/flow/search?${params}`);
|
|
31
|
+
const tasks = r.tasks || [];
|
|
32
|
+
|
|
33
|
+
if (!tasks.length) {
|
|
34
|
+
process.stdout.write('No tasks found.\n');
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
process.stdout.write(`Found ${tasks.length} task${tasks.length === 1 ? '' : 's'}:\n\n`);
|
|
39
|
+
for (const t of tasks) {
|
|
40
|
+
const ref = `#${t.issue_num ?? t.id.slice(0, 6)}`;
|
|
41
|
+
const due = t.due_at ? ` due:${t.due_at.slice(0, 10)}` : '';
|
|
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`
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
main().catch(e => { process.stderr.write('search failed: ' + (e.message || e) + '\n'); process.exit(1); });
|