flowcollab 0.3.21 → 0.3.23

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/_commands.mjs CHANGED
@@ -44,6 +44,8 @@ export const CLI_COMMANDS = [
44
44
  usage: 'flow-handoff --task=<id> --to=<actor> --context="..." [--branch=] [--questions="q1|q2"] [--next-step="..."]' },
45
45
  { name: 'review', file: 'review.mjs', summary: 'Request code review; moves task to in-review',
46
46
  usage: 'flow-review <id> [--pr=<num>] [--reviewer=<actor>] [--context="..."]' },
47
+ { name: 'verify', file: 'verify.mjs', summary: 'Run the project test command + record a pass/fail on the task',
48
+ usage: 'flow-verify <id> [--cmd="npm test"] [--close] — run tests, post a verification event; --close closes on pass' },
47
49
  { name: 'search', file: 'search.mjs', summary: 'Search tasks by text, status, area, or assignee',
48
50
  usage: 'flow-search "query" [--status=] [--area=] [--assignee=]' },
49
51
  { name: 'status', file: 'status.mjs', summary: 'Quick board summary',
@@ -64,8 +66,10 @@ export const CLI_COMMANDS = [
64
66
  usage: 'flow-project [--project=<node_id>] — list GitHub Projects v2 items' },
65
67
  { name: 'archive', file: 'archive.mjs', summary: 'Archive or unarchive a task (--undo to restore)',
66
68
  usage: 'flow-archive <id> [--undo] — archive/unarchive a task (owner only)' },
67
- { name: 'scan', file: 'scan.mjs', summary: 'Audit for TODOs, untracked Issues, unlinked PRs, security patterns',
68
- usage: 'flow-scan [--todos] [--issues] [--prs] [--security] — audit codebase for untracked work' },
69
+ { name: 'scan', file: 'scan.mjs', summary: 'Audit for TODOs, Issues, PRs, security patterns, untested changes',
70
+ usage: 'flow-scan [--todos] [--issues] [--prs] [--security] [--untested] — audit codebase for untracked work' },
71
+ { name: 'scaffold-ci', file: 'scaffold-ci.mjs', summary: 'Drop a starter CI workflow + test skeleton into the repo',
72
+ usage: 'flow-scaffold-ci [--force] [--print] — write .github/workflows/ci.yml + a test skeleton (Theme 35 P4)' },
69
73
  { name: 'close-sprint', file: 'close-sprint.mjs', summary: 'Close a sprint milestone (owner only)',
70
74
  usage: 'flow-close-sprint --milestone=<name> [--archive-done] — owner only' },
71
75
  { name: 'whoami', file: 'whoami.mjs', summary: 'Verify token and show current identity',
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env node
2
+ /* flow:scaffold-ci — drop a starter CI workflow + a test skeleton into the current repo, so a
3
+ Flow team gets a test/CI safety net by default instead of discovering it has none. Mirrors the
4
+ pattern Flow itself dogfoods: `node --check` over the code surface + `node --test`.
5
+
6
+ Usage: flow-scaffold-ci [--force] [--print]
7
+ --print write nothing; print the files to stdout
8
+ --force overwrite existing files (default: skip + warn)
9
+ Purely local — writes files, makes no network/API calls.
10
+ */
11
+
12
+ import { writeFileSync, mkdirSync, existsSync } from 'node:fs';
13
+ import { dirname } from 'node:path';
14
+
15
+ function die(msg) { process.stderr.write('flow-scaffold-ci: ' + msg + '\n'); process.exit(1); }
16
+
17
+ const CI_YML = `name: CI
18
+ on:
19
+ push:
20
+ branches: [main]
21
+ pull_request:
22
+
23
+ jobs:
24
+ test:
25
+ runs-on: ubuntu-latest
26
+ steps:
27
+ - uses: actions/checkout@v4
28
+ - uses: actions/setup-node@v4
29
+ with:
30
+ node-version: 22
31
+ - name: Install deps
32
+ if: \${{ hashFiles('package-lock.json') != '' }}
33
+ run: npm ci
34
+ - name: Syntax-check JS
35
+ run: git ls-files '*.js' '*.mjs' | xargs -r -n1 node --check
36
+ - name: Test
37
+ run: node --test
38
+ `;
39
+
40
+ const SMOKE_TEST = `/* Smoke test — the seed of your suite. Runs with \`node --test\` (no deps).
41
+ Replace with real tests as you build; \`flow scan --untested\` flags changed
42
+ files that still have none. */
43
+ import { test } from 'node:test';
44
+ import assert from 'node:assert/strict';
45
+
46
+ test('the test runner works', () => {
47
+ assert.equal(1 + 1, 2);
48
+ });
49
+ `;
50
+
51
+ const FILES = [
52
+ { path: '.github/workflows/ci.yml', body: CI_YML },
53
+ { path: 'test/smoke.test.mjs', body: SMOKE_TEST },
54
+ ];
55
+
56
+ function main() {
57
+ const args = process.argv.slice(2);
58
+
59
+ if (args.includes('--print')) {
60
+ for (const f of FILES) process.stdout.write(`\n===== ${f.path} =====\n${f.body}`);
61
+ return;
62
+ }
63
+
64
+ const force = args.includes('--force');
65
+ let wrote = 0, skipped = 0;
66
+ for (const f of FILES) {
67
+ if (existsSync(f.path) && !force) {
68
+ process.stdout.write(`skip ${f.path} (exists — pass --force to overwrite)\n`);
69
+ skipped++;
70
+ continue;
71
+ }
72
+ mkdirSync(dirname(f.path), { recursive: true });
73
+ writeFileSync(f.path, f.body);
74
+ process.stdout.write(`write ${f.path}\n`);
75
+ wrote++;
76
+ }
77
+ process.stdout.write(`\n${wrote} written, ${skipped} skipped.`);
78
+ if (wrote) process.stdout.write(` Ensure package.json has a "test" script (or rely on \`node --test\`), commit, and push.`);
79
+ process.stdout.write('\n');
80
+ }
81
+
82
+ try { main(); } catch (e) { die(e.message || e); }
package/bin/scan.mjs CHANGED
@@ -3,16 +3,17 @@
3
3
  Outputs numbered suggestions with ready-to-run flow-create commands.
4
4
 
5
5
  Usage:
6
- flow-scan [--todos] [--issues] [--prs] [--security]
6
+ flow-scan [--todos] [--issues] [--prs] [--security] [--untested]
7
7
  [--dir=<path>] default: cwd
8
8
  [--repo=<owner/repo>] default: FLOW_GITHUB_REPO env var
9
- (no flags = run all four scans)
9
+ (no flags = run all scans)
10
10
  */
11
11
 
12
12
  import 'dotenv/config';
13
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
+ import { spawnSync } from 'node:child_process';
16
17
  import { join, extname, relative } from 'node:path';
17
18
 
18
19
  // ── File walker ────────────────────────────────────────────────────────────────
@@ -209,6 +210,52 @@ function scanSecurity(dir) {
209
210
  return out;
210
211
  }
211
212
 
213
+ // ── Untested-changes scan (Theme 35 P4) ─────────────────────────────────────────
214
+ // Flag changed source files that have no corresponding test. "Covered" = a *.test.* / *.spec.*
215
+ // file, or any file under a test/tests/__tests__/spec dir, whose basename matches the source's.
216
+ function isTestFile(p) {
217
+ return /\.(test|spec)\.[cm]?[jt]sx?$/i.test(p) || /(^|\/)(test|tests|__tests__|spec)\//i.test(p);
218
+ }
219
+ function coveredBasename(p) {
220
+ const base = p.split('/').pop();
221
+ // drop the test/spec suffix, then the extension, then a leading underscore (the `_helper.mjs`
222
+ // convention) so `_commands.mjs` matches `commands.test.js`.
223
+ return base.replace(/\.(test|spec)\.[cm]?[jt]sx?$/i, '').replace(/\.[cm]?[jt]sx?$/i, '').replace(/^_+/, '').toLowerCase();
224
+ }
225
+ function gitLines(dir, args) {
226
+ const r = spawnSync('git', args, { cwd: dir, encoding: 'utf8' });
227
+ return r.status === 0 ? r.stdout.split('\n').map(s => s.trim()).filter(Boolean) : null;
228
+ }
229
+ function changedFiles(dir) {
230
+ // prefer the diff vs the default branch's merge-base (the branch's work); fall back to the working tree
231
+ const base = (gitLines(dir, ['merge-base', 'HEAD', 'origin/main']) || gitLines(dir, ['merge-base', 'HEAD', 'main']) || [])[0];
232
+ if (base) { const d = gitLines(dir, ['diff', '--name-only', `${base}...HEAD`]); if (d && d.length) return d; }
233
+ const staged = gitLines(dir, ['diff', '--name-only', '--cached']) || [];
234
+ const unstaged = gitLines(dir, ['diff', '--name-only']) || [];
235
+ return [...new Set([...staged, ...unstaged])];
236
+ }
237
+ function scanUntested(dir) {
238
+ const changed = changedFiles(dir).map(f => f.replace(/\\/g, '/'));
239
+ const src = changed.filter(f => CODE_EXTS.has(extname(f)) && !isTestFile(f));
240
+ if (!src.length) return [];
241
+ // basenames that already have a test somewhere in the repo
242
+ const covered = new Set(
243
+ walkFiles(dir, () => true).map(f => relative(dir, f).replace(/\\/g, '/')).filter(isTestFile).map(coveredBasename),
244
+ );
245
+ const out = [];
246
+ for (const f of src) {
247
+ const bn = coveredBasename(f);
248
+ if (covered.has(bn)) continue;
249
+ const title = `Add tests for ${f.split('/').pop()}`.replace(/"/g, '\\"');
250
+ out.push({
251
+ label: `No test for ${f}`,
252
+ detail: ` ${f} — changed, but no *.test.* / test-dir file covers "${bn}"`,
253
+ cmd: `flow-create --title="${title}" --type=chore --area=infra`,
254
+ });
255
+ }
256
+ return out.slice(0, 25);
257
+ }
258
+
212
259
  // ── Output ─────────────────────────────────────────────────────────────────────
213
260
  function printSection(heading, suggestions) {
214
261
  process.stdout.write(`\n${heading} (${suggestions.length})\n`);
@@ -227,7 +274,8 @@ async function main() {
227
274
  const doIssues = argv.includes('--issues');
228
275
  const doPRs = argv.includes('--prs');
229
276
  const doSecurity = argv.includes('--security');
230
- const all = !doTodos && !doIssues && !doPRs && !doSecurity;
277
+ const doUntested = argv.includes('--untested');
278
+ const all = !doTodos && !doIssues && !doPRs && !doSecurity && !doUntested;
231
279
 
232
280
  const dir = arg('dir') || process.cwd();
233
281
  const repo = arg('repo') || process.env.FLOW_GITHUB_REPO;
@@ -279,8 +327,15 @@ async function main() {
279
327
  else if (all) process.stdout.write('\n[security] No common patterns found.\n');
280
328
  }
281
329
 
330
+ if (all || doUntested) {
331
+ process.stdout.write(' [untested] checking changed files for tests...\n');
332
+ const s = scanUntested(dir);
333
+ if (doUntested || (all && s.length)) printSection('Changed files with no test', s); // CLI-2: flags combine
334
+ else if (all) process.stdout.write('\n[untested] All changed source files have a test (or no changes detected).\n');
335
+ }
336
+
282
337
  if (all) {
283
- process.stdout.write('\nRun "flow-scan --todos / --issues / --prs / --security" for focused output.\n');
338
+ process.stdout.write('\nRun "flow-scan --todos / --issues / --prs / --security / --untested" for focused output.\n');
284
339
  process.stdout.write('Use any "$ flow-create ..." command above to create a tracking task.\n');
285
340
  }
286
341
  }
package/bin/verify.mjs ADDED
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env node
2
+ /* flow:verify — run the project's test command, record a pass/fail verification on the task
3
+ timeline, and exit with the command's status so an agent's harness can branch on the result.
4
+ Usage: flow-verify <task_id> [--cmd="npm test"] [--close]
5
+ command resolution: --cmd > ./flow.config.json "verify_command" > "npm test"
6
+ --close closes the task (flow-close) on a PASSING run.
7
+ */
8
+
9
+ import { spawnSync } from 'node:child_process';
10
+ import { readFileSync } from 'node:fs';
11
+ import { flowFetch, resolveTaskId, positional, arg, die, sanitizeText } from './_client.mjs';
12
+
13
+ function resolveCommand() {
14
+ const c = arg('cmd');
15
+ if (c) return c;
16
+ try {
17
+ const cfg = JSON.parse(readFileSync('flow.config.json', 'utf8'));
18
+ if (typeof cfg.verify_command === 'string' && cfg.verify_command.trim()) return cfg.verify_command;
19
+ } catch { /* no local config — fall through to the default */ }
20
+ return 'npm test';
21
+ }
22
+
23
+ async function main() {
24
+ const rawId = positional(0);
25
+ if (!rawId) die('Usage: flow-verify <task_id> [--cmd="npm test"] [--close]');
26
+ const id = await resolveTaskId(rawId);
27
+ const command = resolveCommand();
28
+ const doClose = process.argv.slice(2).includes('--close');
29
+ const shortRef = '#' + rawId.replace(/^#/, '').slice(0, 6);
30
+
31
+ process.stdout.write(`Running: ${command}\n`);
32
+ // spawnSync is blocking, so output is buffered then printed; the snippet below is what we record.
33
+ const r = spawnSync(command, { shell: true, stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8' });
34
+ const out = (r.stdout || '') + (r.stderr || '');
35
+ if (out) process.stdout.write(out.endsWith('\n') ? out : out + '\n');
36
+ const passed = r.status === 0;
37
+
38
+ // record the trailing ~20 non-empty lines as the audit snippet
39
+ const snippet = out.split('\n').filter(l => l.trim()).slice(-20).join('\n').slice(0, 4000);
40
+ await flowFetch('/api/flow/verify', { method: 'POST', body: { task_id: id, passed, command, summary: snippet } });
41
+ process.stdout.write(`${passed ? '✓ PASS' : '✗ FAIL'} — recorded on ${shortRef}\n`);
42
+
43
+ if (passed && doClose) {
44
+ await flowFetch('/api/flow/close', { method: 'POST', body: { task_id: id, summary: `Verified: ${sanitizeText(command, 100)}` } });
45
+ process.stdout.write(`Closed ${shortRef}.\n`);
46
+ }
47
+ process.exitCode = r.status ?? 1;
48
+ }
49
+
50
+ main().catch(e => die(e.message || e));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowcollab",
3
- "version": "0.3.21",
3
+ "version": "0.3.23",
4
4
  "description": "Multi-Claude coordination layer — shared task board + CLI for teams running Claude Code",
5
5
  "type": "module",
6
6
  "files": [
@@ -30,6 +30,8 @@
30
30
  "flow-project": "bin/project.mjs",
31
31
  "flow-close-sprint": "bin/close-sprint.mjs",
32
32
  "flow-review": "bin/review.mjs",
33
+ "flow-verify": "bin/verify.mjs",
34
+ "flow-scaffold-ci": "bin/scaffold-ci.mjs",
33
35
  "flow-login": "bin/login.mjs",
34
36
  "flow-edit": "bin/edit.mjs",
35
37
  "flow-unblock": "bin/unblock.mjs",