@ivotoby/postgram-cli 1.38.3 → 1.40.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,579 @@
1
+ // cli/src/tracking/scan.ts: pgm scan — discover worktrees and record their features.
2
+ import { existsSync } from 'node:fs';
3
+ import { readFile, realpath } from 'node:fs/promises';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import readline from 'node:readline';
7
+ import { shortId } from '../shared.js';
8
+ import { runTrackingCommand } from './api.js';
9
+ import { expandHome, isIgnoredPath, slug, usageExit, UsageError } from './config.js';
10
+ import { readMarker, resolveLocalWorkspace, runGit, writeMarker } from './local.js';
11
+ import { discoverWorktrees, findClaudeSessions, findPiSessions } from './scan-discovery.js';
12
+ import { syncMissing } from './where.js';
13
+ export const BRANCH_SHORT_ID = /(?:^|\/)([0-9a-f]{8})(?:-|$)/;
14
+ const DEFAULT_DEPTH = 3;
15
+ function parseDepth(raw) {
16
+ if (raw === undefined) {
17
+ return DEFAULT_DEPTH;
18
+ }
19
+ if (!/^[1-9][0-9]*$/.test(raw)) {
20
+ throw new UsageError(`invalid --depth '${raw}': must be a positive integer`);
21
+ }
22
+ return Number.parseInt(raw, 10);
23
+ }
24
+ function withoutFeatPrefix(branch) {
25
+ return branch.startsWith('feat/') ? branch.slice('feat/'.length) : branch;
26
+ }
27
+ function newFeatureTitle(branch, dirName) {
28
+ if (branch === null || branch === '') {
29
+ return dirName;
30
+ }
31
+ return withoutFeatPrefix(branch).replaceAll('-', ' ');
32
+ }
33
+ async function buildProposals(worktree, deps, tasks, yesExact) {
34
+ const proposals = [];
35
+ const titleFor = (id) => tasks.find((task) => task.id === id)?.title ?? '';
36
+ const marker = await deps.readMarker(worktree.path);
37
+ if (marker !== null) {
38
+ proposals.push({ kind: 'marker', featureId: marker, title: titleFor(marker) });
39
+ }
40
+ if (worktree.branch !== null) {
41
+ const match = BRANCH_SHORT_ID.exec(worktree.branch);
42
+ const short = match?.[1];
43
+ if (short !== undefined) {
44
+ const hits = tasks.filter((task) => task.id.startsWith(short));
45
+ if (hits.length === 1) {
46
+ const hit = hits[0];
47
+ if (hit !== undefined) {
48
+ proposals.push({ kind: 'short-id', featureId: hit.id, title: hit.title });
49
+ }
50
+ }
51
+ }
52
+ if (!yesExact) {
53
+ let hits = [];
54
+ try {
55
+ hits = await deps.search(slug(withoutFeatPrefix(worktree.branch)));
56
+ }
57
+ catch {
58
+ hits = [];
59
+ }
60
+ for (const hit of hits.slice(0, 3)) {
61
+ proposals.push({ kind: 'search', featureId: hit.id, title: hit.title });
62
+ }
63
+ }
64
+ }
65
+ return proposals;
66
+ }
67
+ function sessionsFor(worktree, claude, pi) {
68
+ return [
69
+ ...(claude.get(worktree.path) ?? []),
70
+ ...(pi.get(worktree.path) ?? [])
71
+ ];
72
+ }
73
+ function scanRoots(options, deps) {
74
+ const roots = options.root.length > 0
75
+ ? options.root.map((root) => path.resolve(root))
76
+ : deps.config.scanRoots;
77
+ if (roots.length === 0) {
78
+ throw new UsageError('no scan roots: pass --root <dir> or set scan_roots in ~/.pgmrc');
79
+ }
80
+ return roots;
81
+ }
82
+ async function discoverNotIgnored(roots, depth, deps) {
83
+ const worktrees = await deps.discover(roots, depth);
84
+ return worktrees.filter((worktree) => !isIgnoredPath(worktree.path, deps.config.ignorePaths));
85
+ }
86
+ async function findSessions(paths, deps) {
87
+ const claudeDir = deps.env.CLAUDE_CONFIG_DIR ?? path.join(deps.homeDir, '.claude');
88
+ const piDir = deps.env.PI_CODING_AGENT_DIR ?? path.join(deps.homeDir, '.pi', 'agent');
89
+ const claude = await deps.claudeSessions(paths, claudeDir);
90
+ for (const note of claude.notes) {
91
+ deps.info(note);
92
+ }
93
+ const pi = await deps.piSessions(paths, piDir);
94
+ return { claude: claude.sessions, pi };
95
+ }
96
+ async function recordLocal(local, featureId, sessions, deps) {
97
+ const body = {
98
+ source: 'scan',
99
+ machine: {
100
+ alias: deps.config.alias,
101
+ hostname: deps.hostname,
102
+ boot_id: null
103
+ },
104
+ workspace: local.workspace,
105
+ feature_id: featureId,
106
+ sessions
107
+ };
108
+ await deps.client.report(body);
109
+ if ((await deps.readMarker(local.path)) !== featureId) {
110
+ await deps.writeMarker(local.path, featureId);
111
+ }
112
+ }
113
+ export async function runScan(options, deps) {
114
+ if (!options.yesExact && !deps.isTty()) {
115
+ throw new UsageError('pgm scan needs a terminal for its interactive flow. Without one, use `pgm scan --json` to list worktrees, `pgm scan --apply <plan.json>` to apply a plan, or `pgm scan --yes-exact`.');
116
+ }
117
+ const roots = scanRoots(options, deps);
118
+ const depth = parseDepth(options.depth);
119
+ const knownResponse = await deps.client.listWorkspaces({
120
+ machine: deps.config.alias
121
+ });
122
+ if (!options.dryRun) {
123
+ await syncMissing(knownResponse.workspaces, {
124
+ localAlias: deps.config.alias,
125
+ exists: deps.exists,
126
+ patch: (id, missing) => deps.client.patchWorkspace(id, { missing })
127
+ });
128
+ }
129
+ const known = new Set(knownResponse.workspaces.map((workspace) => workspace.path));
130
+ const worktrees = await discoverNotIgnored(roots, depth, deps);
131
+ const { claude, pi } = await findSessions(worktrees.map((worktree) => worktree.path), deps);
132
+ const tasks = await deps.tasks();
133
+ const lines = [];
134
+ for (const worktree of worktrees) {
135
+ if (known.has(worktree.path)) {
136
+ lines.push(`known ${worktree.path}`);
137
+ continue;
138
+ }
139
+ const proposals = await buildProposals(worktree, deps, tasks, options.yesExact);
140
+ let featureId = null;
141
+ if (options.yesExact) {
142
+ const first = proposals[0];
143
+ if (first !== undefined &&
144
+ (first.kind === 'marker' || first.kind === 'short-id')) {
145
+ featureId = first.featureId;
146
+ }
147
+ else {
148
+ lines.push(`skipped ${worktree.path}`);
149
+ continue;
150
+ }
151
+ }
152
+ else {
153
+ deps.info(`${worktree.path}${worktree.branch !== null ? ` (${worktree.branch})` : ''}`);
154
+ proposals.forEach((proposal, index) => {
155
+ deps.info(` ${index + 1}. ${proposal.kind}: ${proposal.title !== '' ? proposal.title : proposal.featureId}`);
156
+ });
157
+ const answer = (await deps.ask('[a]ccept, [1-3] pick, [n]ew feature, [s]kip'))
158
+ .trim()
159
+ .toLowerCase();
160
+ if (answer === 'a') {
161
+ const first = proposals[0];
162
+ if (first !== undefined) {
163
+ featureId = first.featureId;
164
+ }
165
+ }
166
+ else if (/^[1-9]$/.test(answer)) {
167
+ const picked = proposals[Number.parseInt(answer, 10) - 1];
168
+ if (picked !== undefined) {
169
+ featureId = picked.featureId;
170
+ }
171
+ }
172
+ else if (answer === 'n') {
173
+ const created = await deps.client.createFeature({
174
+ content: newFeatureTitle(worktree.branch, path.basename(worktree.path))
175
+ });
176
+ featureId = created.entity.id;
177
+ }
178
+ if (featureId === null) {
179
+ lines.push(`skipped ${worktree.path}`);
180
+ continue;
181
+ }
182
+ }
183
+ const id = featureId;
184
+ if (options.dryRun) {
185
+ lines.push(`would record ${worktree.path} → ${shortId(id)}`);
186
+ continue;
187
+ }
188
+ const local = await deps.resolveWorkspace(worktree.path);
189
+ if (local.kind === 'timeout') {
190
+ lines.push(`skipped ${worktree.path} (git timeout)`);
191
+ continue;
192
+ }
193
+ await recordLocal(local, id, sessionsFor(worktree, claude, pi), deps);
194
+ lines.push(`recorded ${worktree.path} → ${shortId(id)}`);
195
+ }
196
+ return lines;
197
+ }
198
+ const INVENTORY_CONCURRENCY = 6;
199
+ function sessionSummary(sessions) {
200
+ if (sessions.length === 0) {
201
+ return null;
202
+ }
203
+ let latest = '';
204
+ for (const session of sessions) {
205
+ const observed = session.observed_at ?? '';
206
+ if (observed > latest) {
207
+ latest = observed;
208
+ }
209
+ }
210
+ return { count: sessions.length, last_activity_at: latest };
211
+ }
212
+ /** Read-only: lists what is on disk and what Postgram knows. Sends nothing. */
213
+ export async function runScanInventory(options, deps) {
214
+ const roots = scanRoots(options, deps);
215
+ const depth = parseDepth(options.depth);
216
+ const known = new Map((await deps.client.listWorkspaces({ machine: deps.config.alias })).workspaces.map((workspace) => [workspace.path, workspace]));
217
+ const worktrees = await discoverNotIgnored(roots, depth, deps);
218
+ const { claude, pi } = await findSessions(worktrees.map((worktree) => worktree.path), deps);
219
+ const tasks = await deps.tasks();
220
+ const titleFor = (id) => tasks.find((task) => task.id === id)?.title ?? '';
221
+ const describe = async (worktree) => {
222
+ const [local, lastCommit] = await Promise.all([
223
+ deps.resolveWorkspace(worktree.path),
224
+ deps.lastCommit(worktree.path)
225
+ ]);
226
+ const workspace = known.get(worktree.path) ?? null;
227
+ const linkedId = workspace?.feature_id ?? null;
228
+ const marker = await deps.readMarker(worktree.path);
229
+ const candidates = (linkedId === null
230
+ ? await buildProposals(worktree, deps, tasks, false)
231
+ : []).map((proposal) => ({
232
+ kind: proposal.kind,
233
+ feature_id: proposal.featureId,
234
+ title: proposal.title
235
+ }));
236
+ const entry = {
237
+ path: worktree.path,
238
+ repo_remote: local.kind === 'ok'
239
+ ? (local.workspace.repo_remote ?? workspace?.repo_remote ?? null)
240
+ : (workspace?.repo_remote ?? null),
241
+ branch: local.kind === 'ok'
242
+ ? (local.workspace.branch ?? worktree.branch)
243
+ : worktree.branch,
244
+ last_commit: lastCommit,
245
+ git: local.kind === 'ok' ? (local.workspace.git ?? null) : null,
246
+ workspace_id: workspace?.id ?? null,
247
+ feature: linkedId !== null ? { id: linkedId, title: titleFor(linkedId) } : null,
248
+ marker_feature_id: marker,
249
+ sessions: sessionSummary(sessionsFor(worktree, claude, pi)),
250
+ candidates
251
+ };
252
+ if (local.kind === 'timeout') {
253
+ entry.note = 'git timeout';
254
+ }
255
+ return entry;
256
+ };
257
+ const entries = [];
258
+ for (let i = 0; i < worktrees.length; i += INVENTORY_CONCURRENCY) {
259
+ entries.push(...(await Promise.all(worktrees.slice(i, i + INVENTORY_CONCURRENCY).map(describe))));
260
+ }
261
+ return {
262
+ machine: deps.config.alias,
263
+ generated_at: new Date().toISOString(),
264
+ roots,
265
+ worktrees: entries
266
+ };
267
+ }
268
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
269
+ const SHORT_ID_RE = /^[0-9a-f]{8}$/i;
270
+ function isRecordValue(value) {
271
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
272
+ }
273
+ function resolveTaskId(raw, tasks) {
274
+ const lower = raw.toLowerCase();
275
+ let hits = [];
276
+ if (UUID_RE.test(lower)) {
277
+ hits = tasks.filter((task) => task.id === lower);
278
+ }
279
+ else if (SHORT_ID_RE.test(lower)) {
280
+ hits = tasks.filter((task) => task.id.startsWith(lower));
281
+ }
282
+ const hit = hits[0];
283
+ if (hit === undefined) {
284
+ return { error: `no feature matches ${raw}` };
285
+ }
286
+ if (hits.length > 1) {
287
+ return { error: `feature id ${raw} is ambiguous` };
288
+ }
289
+ return { id: hit.id };
290
+ }
291
+ /** Validates the whole plan; nothing is written while this runs. */
292
+ async function validatePlan(plan, deps) {
293
+ const rawEntries = Array.isArray(plan)
294
+ ? plan
295
+ : isRecordValue(plan) && Array.isArray(plan.entries)
296
+ ? plan.entries
297
+ : null;
298
+ if (rawEntries === null) {
299
+ throw new UsageError('plan must be {"entries": [...]} or a JSON array of entries');
300
+ }
301
+ const tasks = await deps.tasks();
302
+ const errors = [];
303
+ const valid = [];
304
+ const seen = new Set();
305
+ for (const [index, raw] of rawEntries.entries()) {
306
+ if (!isRecordValue(raw) || typeof raw.path !== 'string' || raw.path === '') {
307
+ errors.push(`entry ${index + 1}: path is required`);
308
+ continue;
309
+ }
310
+ const expanded = expandHome(raw.path, deps.homeDir);
311
+ if (!path.isAbsolute(expanded)) {
312
+ errors.push(`${raw.path}: path must be absolute`);
313
+ continue;
314
+ }
315
+ const target = path.resolve(expanded);
316
+ if (seen.has(target)) {
317
+ errors.push(`duplicate path ${target}`);
318
+ continue;
319
+ }
320
+ seen.add(target);
321
+ const action = raw.action;
322
+ if (action === 'skip') {
323
+ valid.push({ entry: { path: target, action: 'skip' } });
324
+ continue;
325
+ }
326
+ let item;
327
+ if (action === 'link') {
328
+ if (typeof raw.feature_id !== 'string' || raw.feature_id === '') {
329
+ errors.push(`${target}: link needs feature_id`);
330
+ continue;
331
+ }
332
+ const resolved = resolveTaskId(raw.feature_id, tasks);
333
+ if ('error' in resolved) {
334
+ errors.push(`${target}: ${resolved.error}`);
335
+ continue;
336
+ }
337
+ item = {
338
+ entry: { path: target, action: 'link', feature_id: raw.feature_id },
339
+ featureId: resolved.id
340
+ };
341
+ }
342
+ else if (action === 'create') {
343
+ const title = typeof raw.title === 'string' ? raw.title.trim() : '';
344
+ if (title === '') {
345
+ errors.push(`${target}: create needs a non-empty title`);
346
+ continue;
347
+ }
348
+ item = { entry: { path: target, action: 'create', title } };
349
+ }
350
+ else {
351
+ errors.push(`${target}: unknown action '${String(action)}'`);
352
+ continue;
353
+ }
354
+ if (isIgnoredPath(target, deps.config.ignorePaths)) {
355
+ errors.push(`${target}: path is in ignore_paths`);
356
+ continue;
357
+ }
358
+ if (!deps.exists(target)) {
359
+ errors.push(`${target}: path does not exist`);
360
+ continue;
361
+ }
362
+ const local = await deps.resolveWorkspace(target);
363
+ if (local.kind === 'timeout') {
364
+ errors.push(`${target}: git timed out`);
365
+ continue;
366
+ }
367
+ if (local.repoRoot === null) {
368
+ errors.push(`${target}: not a git worktree`);
369
+ continue;
370
+ }
371
+ if (local.path !== (await deps.realpath(target))) {
372
+ errors.push(`${target}: not the root of a worktree (${local.path})`);
373
+ continue;
374
+ }
375
+ if (isIgnoredPath(local.path, deps.config.ignorePaths)) {
376
+ errors.push(`${target}: path is in ignore_paths`);
377
+ continue;
378
+ }
379
+ valid.push({ ...item, local });
380
+ }
381
+ if (errors.length > 0) {
382
+ throw new UsageError(`plan rejected, nothing was written:\n${errors.map((line) => ` ${line}`).join('\n')}`);
383
+ }
384
+ return valid;
385
+ }
386
+ export async function runScanApply(plan, options, deps) {
387
+ const valid = await validatePlan(plan, deps);
388
+ const paths = valid
389
+ .filter((item) => item.entry.action !== 'skip')
390
+ .map((item) => item.entry.path);
391
+ const sessions = options.dryRun || paths.length === 0
392
+ ? { claude: new Map(), pi: new Map() }
393
+ : await findSessions(paths, deps);
394
+ const results = [];
395
+ for (const item of valid) {
396
+ const { entry } = item;
397
+ if (entry.action === 'skip') {
398
+ results.push({ path: entry.path, action: 'skip', status: 'skipped' });
399
+ continue;
400
+ }
401
+ if (options.dryRun) {
402
+ results.push({
403
+ path: entry.path,
404
+ action: entry.action,
405
+ status: entry.action === 'link' ? 'would link' : 'would create',
406
+ ...(item.featureId !== undefined ? { feature_id: item.featureId } : {})
407
+ });
408
+ continue;
409
+ }
410
+ let featureId = item.featureId;
411
+ try {
412
+ if (entry.action === 'create') {
413
+ const created = await deps.client.createFeature({ content: entry.title });
414
+ featureId = created.entity.id;
415
+ }
416
+ if (featureId === undefined || item.local === undefined) {
417
+ throw new Error('internal: unresolved plan entry');
418
+ }
419
+ await recordLocal(item.local, featureId, sessionsFor({ path: entry.path, branch: null }, sessions.claude, sessions.pi), deps);
420
+ results.push({
421
+ path: entry.path,
422
+ action: entry.action,
423
+ status: entry.action === 'link' ? 'linked' : 'created',
424
+ feature_id: featureId
425
+ });
426
+ }
427
+ catch (error) {
428
+ results.push({
429
+ path: entry.path,
430
+ action: entry.action,
431
+ status: 'failed',
432
+ ...(featureId !== undefined ? { feature_id: featureId } : {}),
433
+ error: error instanceof Error ? error.message : 'unknown error'
434
+ });
435
+ }
436
+ }
437
+ return {
438
+ results,
439
+ failed: results.filter((result) => result.status === 'failed').length
440
+ };
441
+ }
442
+ export function formatApplyResults(results) {
443
+ return results.map((result) => {
444
+ const target = result.feature_id !== undefined ? ` → ${shortId(result.feature_id)}` : '';
445
+ if (result.status === 'failed') {
446
+ return `failed ${result.path}${target}: ${result.error ?? 'unknown error'}`;
447
+ }
448
+ return `${result.status} ${result.path}${target}`;
449
+ });
450
+ }
451
+ function firstLineTitle(content) {
452
+ return (content ?? '').split('\n')[0]?.trim() ?? '';
453
+ }
454
+ function collectRoot(value, previous) {
455
+ return [...previous, value];
456
+ }
457
+ function askLine(question) {
458
+ return new Promise((resolve) => {
459
+ let settled = false;
460
+ const rl = readline.createInterface({
461
+ input: process.stdin,
462
+ output: process.stderr
463
+ });
464
+ const finish = (answer) => {
465
+ if (settled) {
466
+ return;
467
+ }
468
+ settled = true;
469
+ rl.close();
470
+ resolve(answer.trim());
471
+ };
472
+ rl.question(question, (answer) => finish(answer));
473
+ rl.on('close', () => finish(''));
474
+ rl.on('SIGINT', () => finish(''));
475
+ });
476
+ }
477
+ async function readPlan(file) {
478
+ let text;
479
+ try {
480
+ text = await readFile(file, 'utf8');
481
+ }
482
+ catch (error) {
483
+ throw new UsageError(`cannot read plan ${file}: ${error instanceof Error ? error.message : 'read failed'}`);
484
+ }
485
+ try {
486
+ return JSON.parse(text);
487
+ }
488
+ catch (error) {
489
+ throw new UsageError(`plan ${file} is not valid JSON: ${error instanceof Error ? error.message : 'parse failed'}`);
490
+ }
491
+ }
492
+ export function registerScanCommand(program) {
493
+ const command = usageExit(program
494
+ .command('scan')
495
+ .description('Discover worktrees under the scan roots and record their features')
496
+ .option('--root <dir>', 'scan root (repeatable)', collectRoot, [])
497
+ .option('--depth <n>', `directory depth below each root (default ${DEFAULT_DEPTH})`)
498
+ .option('--yes-exact', 'accept marker and short-id proposals, skip the rest')
499
+ .option('--dry-run', 'show what would be recorded without sending')
500
+ .option('--apply <plan.json>', 'apply an approved plan (link, create or skip per worktree path)')).action(async () => {
501
+ const opts = command.opts();
502
+ await runTrackingCommand(command, async (context) => {
503
+ const scanOptions = {
504
+ root: opts.root ?? [],
505
+ ...(opts.depth !== undefined ? { depth: opts.depth } : {}),
506
+ yesExact: opts.yesExact === true,
507
+ dryRun: opts.dryRun === true
508
+ };
509
+ if (opts.apply !== undefined && scanOptions.yesExact) {
510
+ throw new UsageError('--apply cannot be combined with --yes-exact');
511
+ }
512
+ const deps = {
513
+ client: context.client,
514
+ config: context.config,
515
+ hostname: os.hostname(),
516
+ env: process.env,
517
+ homeDir: context.homeDir,
518
+ tasks: async () => {
519
+ const items = [];
520
+ for (let offset = 0;; offset += 100) {
521
+ const page = await context.pgm.listTasks({ limit: 100, offset });
522
+ for (const item of page.items) {
523
+ items.push({ id: item.id, title: firstLineTitle(item.content) });
524
+ }
525
+ if (page.items.length < 100) {
526
+ break;
527
+ }
528
+ }
529
+ return items;
530
+ },
531
+ search: async (query) => {
532
+ const response = await context.pgm.searchEntities({
533
+ query,
534
+ type: 'task',
535
+ limit: 3
536
+ });
537
+ return response.results.map((result) => ({
538
+ id: result.entity.id,
539
+ title: firstLineTitle(result.entity.content)
540
+ }));
541
+ },
542
+ ask: askLine,
543
+ discover: discoverWorktrees,
544
+ claudeSessions: findClaudeSessions,
545
+ piSessions: findPiSessions,
546
+ resolveWorkspace: (p) => resolveLocalWorkspace(p),
547
+ readMarker,
548
+ writeMarker: (worktree, featureId) => writeMarker(worktree, featureId, new Date()),
549
+ exists: existsSync,
550
+ isTty: () => process.stdin.isTTY === true,
551
+ realpath: (p) => realpath(p),
552
+ lastCommit: async (worktree) => {
553
+ const result = await runGit(['log', '-1', '--format=%cI%n%s'], {
554
+ cwd: worktree,
555
+ timeoutMs: 5000
556
+ });
557
+ if (!result.ok || result.stdout === '') {
558
+ return null;
559
+ }
560
+ const [date = '', ...subject] = result.stdout.split('\n');
561
+ return { date, subject: subject.join('\n') };
562
+ },
563
+ info: (line) => process.stderr.write(`${line}\n`)
564
+ };
565
+ if (opts.apply !== undefined) {
566
+ const plan = await readPlan(opts.apply);
567
+ const applied = await runScanApply(plan, { dryRun: scanOptions.dryRun }, deps);
568
+ if (applied.failed > 0) {
569
+ process.exitCode = 1;
570
+ }
571
+ return context.json ? applied : formatApplyResults(applied.results);
572
+ }
573
+ if (context.json && !scanOptions.yesExact) {
574
+ return runScanInventory(scanOptions, deps);
575
+ }
576
+ return runScan(scanOptions, deps);
577
+ });
578
+ });
579
+ }
@@ -0,0 +1,14 @@
1
+ export const AGENT_KINDS = [
2
+ 'claude-code',
3
+ 'pi',
4
+ 'talon',
5
+ 'other'
6
+ ];
7
+ export const FEATURE_STAGES = [
8
+ 'building',
9
+ 'review',
10
+ 'functional_test',
11
+ 'ready_to_merge',
12
+ 'awaiting_release',
13
+ 'released'
14
+ ];