@4ge/cli 0.1.0 → 0.2.1

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/dist/base.js CHANGED
@@ -117,7 +117,7 @@ export class BaseCommand extends Command {
117
117
  options?.onStatusChange?.('refreshing-token');
118
118
  const credential = await ensureFreshToken();
119
119
  if (!credential) {
120
- const message = 'Not authenticated. Run `4ge auth login --token <api_key>` first.';
120
+ const message = 'Not authenticated. Run `4ge auth:login --token <api_key>` first.';
121
121
  if (this.isJsonMode) {
122
122
  this.outputErrorAndExit({ message, code: 'NO_CREDENTIALS' });
123
123
  }
@@ -12,7 +12,7 @@ export default class AuthStatus extends BaseCommand {
12
12
  this.outputErrorAndExit({ message: 'Not authenticated', code: 'NO_CREDENTIALS' });
13
13
  }
14
14
  else {
15
- this.log('Not authenticated. Run `4ge auth login --token <api_key>` to authenticate.');
15
+ this.log('Not authenticated. Run `4ge auth:login --token <api_key>` to authenticate.');
16
16
  }
17
17
  return;
18
18
  }
@@ -37,7 +37,7 @@ export default class AuthStatus extends BaseCommand {
37
37
  this.outputErrorAndExit({
38
38
  message,
39
39
  code,
40
- hint: refreshError.message || "Run '4ge auth login' to re-authenticate."
40
+ hint: refreshError.message || "Run '4ge auth:login' to re-authenticate."
41
41
  });
42
42
  }
43
43
  else {
@@ -47,7 +47,7 @@ export default class AuthStatus extends BaseCommand {
47
47
  else {
48
48
  this.log('⚠ Session expired — token refresh failed.');
49
49
  }
50
- this.log(` ${refreshError.message || "Run '4ge auth login' to re-authenticate."}`);
50
+ this.log(` ${refreshError.message || "Run '4ge auth:login' to re-authenticate."}`);
51
51
  }
52
52
  return;
53
53
  }
@@ -65,7 +65,7 @@ export default class AuthStatus extends BaseCommand {
65
65
  this.outputErrorAndExit({
66
66
  message: 'API validation failed for 4GE_API_KEY',
67
67
  code: 'ENV_CREDENTIAL_OVERRIDE',
68
- hint: 'The 4GE_API_KEY environment variable is overriding stored credentials but is invalid. Unset it to use credentials from "4ge auth login".'
68
+ hint: 'The 4GE_API_KEY environment variable is overriding stored credentials but is invalid. Unset it to use credentials from "4ge auth:login".'
69
69
  });
70
70
  }
71
71
  else {
@@ -77,7 +77,7 @@ export default class AuthStatus extends BaseCommand {
77
77
  this.log('⚠ Using API key from 4GE_API_KEY environment variable, but server validation failed.');
78
78
  this.log('');
79
79
  this.log('The 4GE_API_KEY environment variable takes priority over stored credentials.');
80
- this.log('FIX: Unset 4GE_API_KEY (e.g., `unset 4GE_API_KEY`) to use credentials from `4ge auth login`.');
80
+ this.log('FIX: Unset 4GE_API_KEY (e.g., `unset 4GE_API_KEY`) to use credentials from `4ge auth:login`.');
81
81
  }
82
82
  else {
83
83
  this.log(`Authenticated via ${activeCredential.source}${activeCredential.profile ? ` (profile: ${activeCredential.profile})` : ''}, but API validation failed.`);
@@ -3,7 +3,7 @@ export default class ConfigIndex extends Command {
3
3
  static description = 'Manage CLI configuration';
4
4
  static hidden = true;
5
5
  async run() {
6
- this.log('Use `4ge config manage` for interactive configuration.');
7
- this.log('Use `4ge config show`, `4ge config get`, or `4ge config set` for direct access.');
6
+ this.log('Use `4ge config:manage` for interactive configuration.');
7
+ this.log('Use `4ge config:show`, `4ge config:get`, or `4ge config:set` for direct access.');
8
8
  }
9
9
  }
@@ -26,8 +26,8 @@ export default class ConfigShow extends BaseCommand {
26
26
  ] }));
27
27
  if (this.isInteractiveMode) {
28
28
  await this.handleActionHints([
29
- { label: 'Set config', command: '4ge config set <key> <value>' },
30
- { label: 'Get config', command: '4ge config get <key>' },
29
+ { label: 'Set config', command: '4ge config:set <key> <value>' },
30
+ { label: 'Get config', command: '4ge config:get <key>' },
31
31
  ]);
32
32
  }
33
33
  }
@@ -75,7 +75,7 @@ export default class EpicList extends BaseCommand {
75
75
  const selectedId = await this.selectOption('Select an epic for details:', options, { headerVariant: 'compact' });
76
76
  if (selectedId) {
77
77
  const { default: chalk } = await import('chalk');
78
- this.log(`\n 🚀 Run: ${chalk.cyan(`4ge epic show ${selectedId}`)}\n`);
78
+ this.log(`\n 🚀 Run: ${chalk.cyan(`4ge epic:show ${selectedId}`)}\n`);
79
79
  }
80
80
  }
81
81
  if (metadata?.action_hints) {
@@ -77,7 +77,7 @@ export default class FeatureList extends BaseCommand {
77
77
  const selectedId = await this.selectOption('Select a feature for details:', options, { headerVariant: 'compact' });
78
78
  if (selectedId) {
79
79
  const { default: chalk } = await import('chalk');
80
- this.log(`\n 🚀 Run: ${chalk.cyan(`4ge feature show ${selectedId}`)}\n`);
80
+ this.log(`\n 🚀 Run: ${chalk.cyan(`4ge feature:show ${selectedId}`)}\n`);
81
81
  }
82
82
  }
83
83
  if (metadata?.action_hints) {
@@ -0,0 +1,10 @@
1
+ import { Help } from '@oclif/core';
2
+ import { BaseCommand } from '../../base.js';
3
+ export default class FlowIndex extends BaseCommand {
4
+ static description = 'Manage feature flows in your project';
5
+ async run() {
6
+ await this.renderHeader('ready', 'compact');
7
+ const help = new Help(this.config);
8
+ await help.showHelp(['flow']);
9
+ }
10
+ }
@@ -0,0 +1,85 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Flags } from '@oclif/core';
3
+ import { BaseCommand } from '../../base.js';
4
+ import { listFlows } from '../../generated/api/sdk.gen.js';
5
+ import { zListFlowsResponse } from '../../generated/api/zod.gen.js';
6
+ import { validateResponse } from '../../core/api/index.js';
7
+ import { DataTable } from '../../ui/index.js';
8
+ export default class FlowList extends BaseCommand {
9
+ static description = 'List flows for a feature in the current project';
10
+ static examples = [
11
+ '<%= config.bin %> flow:list --feature-id <uuid>',
12
+ '<%= config.bin %> flow:list --feature-id <uuid> --roadmap now --json',
13
+ '<%= config.bin %> flow:list --feature-id <uuid> --status IN_DEVELOPMENT',
14
+ ];
15
+ static flags = {
16
+ ...BaseCommand.baseFlags,
17
+ 'feature-id': Flags.string({
18
+ description: 'Feature ID whose flows to list (a feature owns one implied flow)',
19
+ required: true,
20
+ }),
21
+ roadmap: Flags.string({
22
+ description: 'Filter by roadmap status (now|next|later)',
23
+ options: ['now', 'next', 'later'],
24
+ }),
25
+ status: Flags.string({
26
+ description: 'Filter by status (e.g. BACKLOG, IN_DEVELOPMENT, COMPLETE)',
27
+ }),
28
+ };
29
+ async run() {
30
+ const { flags } = await this.parse(FlowList);
31
+ await this.renderHeader('ready', 'compact');
32
+ const client = await this.getClient();
33
+ const projectId = await this.getProjectIdOrExit();
34
+ try {
35
+ const response = await this.withSpinner('Fetching flows...', () => listFlows({
36
+ client,
37
+ path: {
38
+ projectId,
39
+ featureId: flags['feature-id'],
40
+ },
41
+ query: {
42
+ ...(flags.roadmap ? { roadmap: flags.roadmap } : {}),
43
+ ...(flags.status ? { status: flags.status } : {}),
44
+ },
45
+ }));
46
+ const { data, error } = response;
47
+ const validated = validateResponse(data, error, zListFlowsResponse);
48
+ const items = validated.data;
49
+ const metadata = validated.metadata;
50
+ if (this.isJsonMode) {
51
+ this.outputJson(items, { source: 'flow list' });
52
+ }
53
+ else {
54
+ await this.renderInk(_jsx(DataTable, { data: items, columns: [
55
+ { key: 'label', header: 'Title', minWidth: 20 },
56
+ { key: 'id', header: 'ID' },
57
+ { key: 'status', header: 'Status', color: 'auto' },
58
+ { key: 'roadmap_status', header: 'Roadmap' },
59
+ { key: 'step_count', header: 'Steps' },
60
+ { key: 'parent_feature_id', header: 'Feature ID' },
61
+ ], title: `Flows in feature ${flags['feature-id']}${flags.roadmap ? ` (${flags.roadmap})` : ''}`, emptyMessage: `No flows found${flags.roadmap ? ` for roadmap: ${flags.roadmap}` : ''}` }));
62
+ if (this.isInteractiveMode) {
63
+ if (items.length > 0) {
64
+ const options = items.map((item) => ({
65
+ label: item.label || item.id,
66
+ value: item.id,
67
+ description: item.status || '',
68
+ }));
69
+ const selectedId = await this.selectOption('Select a flow for details:', options, { headerVariant: 'compact' });
70
+ if (selectedId) {
71
+ const { default: chalk } = await import('chalk');
72
+ this.log(`\n 🚀 Run: ${chalk.cyan(`4ge flow:show ${selectedId}`)}\n`);
73
+ }
74
+ }
75
+ if (metadata?.action_hints) {
76
+ await this.handleActionHints(metadata.action_hints);
77
+ }
78
+ }
79
+ }
80
+ }
81
+ catch (err) {
82
+ this.handleError('An unexpected error occurred', err);
83
+ }
84
+ }
85
+ }
@@ -0,0 +1,109 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Flags } from '@oclif/core';
3
+ import { BaseCommand } from '../../base.js';
4
+ import { getCollectionDir } from '../../core/config/index.js';
5
+ import { resolveRecursive } from '../../core/config/defaults.js';
6
+ import { getFlow, getFlowTree } from '../../generated/api/sdk.gen.js';
7
+ import { zGetFlowTreeResponse, zGetFlowResponse } from '../../generated/api/zod.gen.js';
8
+ import { validateResponse } from '../../core/api/index.js';
9
+ import { SuccessMessage } from '../../ui/index.js';
10
+ import fs from 'node:fs/promises';
11
+ import path from 'node:path';
12
+ export default class FlowPull extends BaseCommand {
13
+ static description = 'Download flow details';
14
+ static examples = [
15
+ '<%= config.bin %> flow:pull --id <uuid>',
16
+ '<%= config.bin %> flow:pull --id <uuid> --json',
17
+ '<%= config.bin %> flow:pull --id <uuid> --recursive',
18
+ ];
19
+ static flags = {
20
+ ...BaseCommand.baseFlags,
21
+ id: Flags.string({
22
+ description: 'Flow ID',
23
+ required: true,
24
+ }),
25
+ recursive: Flags.boolean({
26
+ description: 'Pull full flow tree (flow + step nodes) instead of just the flow detail',
27
+ allowNo: true,
28
+ }),
29
+ save: Flags.boolean({
30
+ description: 'Force save to disk even in JSON mode',
31
+ default: false,
32
+ }),
33
+ stdout: Flags.boolean({
34
+ description: 'Print to stdout without saving',
35
+ default: false,
36
+ }),
37
+ };
38
+ async run() {
39
+ const { flags } = await this.parse(FlowPull);
40
+ if (flags.json && flags.stdout) {
41
+ this.error('Cannot use --json and --stdout together');
42
+ }
43
+ if (flags.save && flags.stdout) {
44
+ this.error('Cannot use --stdout and --save together');
45
+ }
46
+ await this.renderHeader('ready', 'compact');
47
+ await this.migrateAssetsIfNeeded();
48
+ const client = await this.getClient();
49
+ const projectId = await this.getProjectIdOrExit();
50
+ const isRecursive = resolveRecursive(flags.recursive, this.localConfig);
51
+ const spinnerMsg = isRecursive ? `Pulling flow ${flags.id} tree...` : `Pulling flow ${flags.id}...`;
52
+ const response = await this.withSpinner(spinnerMsg, () => {
53
+ if (isRecursive) {
54
+ return getFlowTree({
55
+ client,
56
+ path: {
57
+ projectId,
58
+ flowId: flags.id,
59
+ },
60
+ });
61
+ }
62
+ else {
63
+ return getFlow({
64
+ client,
65
+ path: {
66
+ projectId,
67
+ flowId: flags.id,
68
+ },
69
+ });
70
+ }
71
+ });
72
+ const { data, error } = response;
73
+ try {
74
+ let flow;
75
+ let metadata;
76
+ if (isRecursive) {
77
+ const validated = validateResponse(data, error, zGetFlowTreeResponse);
78
+ flow = validated.data;
79
+ metadata = validated.metadata;
80
+ }
81
+ else {
82
+ const validated = validateResponse(data, error, zGetFlowResponse);
83
+ flow = validated.data;
84
+ metadata = validated.metadata;
85
+ }
86
+ const savePath = path.join(getCollectionDir('flows'), `${flags.id}.json`);
87
+ if (this.isJsonMode) {
88
+ this.outputJson(flow);
89
+ if (flags.save) {
90
+ await this.saveToFile(savePath, flow);
91
+ }
92
+ }
93
+ else if (flags.stdout) {
94
+ this.log(JSON.stringify(flow, null, 2));
95
+ }
96
+ else {
97
+ await this.saveToFile(savePath, flow);
98
+ await this.renderInk(_jsx(SuccessMessage, { message: `Flow saved to .4ge/assets/flows/${flags.id}.json` }));
99
+ }
100
+ }
101
+ catch (err) {
102
+ this.handleError(`Failed to pull flow: ${flags.id}`, err);
103
+ }
104
+ }
105
+ async saveToFile(filePath, data) {
106
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
107
+ await fs.writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8');
108
+ }
109
+ }
@@ -0,0 +1,58 @@
1
+ import { Flags } from '@oclif/core';
2
+ import { BaseCommand } from '../../base.js';
3
+ import { getFlow } from '../../generated/api/sdk.gen.js';
4
+ import { zGetFlowResponse } from '../../generated/api/zod.gen.js';
5
+ import { validateResponse } from '../../core/api/index.js';
6
+ export default class FlowShow extends BaseCommand {
7
+ static description = 'Show detailed information for a specific flow';
8
+ static examples = [
9
+ '<%= config.bin %> flow:show --id <uuid>',
10
+ '<%= config.bin %> flow:show --id <uuid> --json',
11
+ ];
12
+ static flags = {
13
+ ...BaseCommand.baseFlags,
14
+ id: Flags.string({
15
+ description: 'Flow ID',
16
+ required: true,
17
+ }),
18
+ };
19
+ async run() {
20
+ const { flags } = await this.parse(FlowShow);
21
+ const client = await this.getClient();
22
+ const projectId = await this.getProjectIdOrExit();
23
+ await this.renderHeader('ready', 'compact');
24
+ const response = await this.withSpinner(`Fetching flow ${flags.id}...`, () => getFlow({
25
+ client,
26
+ path: {
27
+ projectId,
28
+ flowId: flags.id,
29
+ },
30
+ }));
31
+ const { data, error } = response;
32
+ try {
33
+ const validated = validateResponse(data, error, zGetFlowResponse);
34
+ const payload = validated.data;
35
+ const metadata = validated.metadata;
36
+ if (this.isJsonMode) {
37
+ this.outputJson(payload, { source: 'flow show' });
38
+ }
39
+ else {
40
+ this.log(`\nFlow: ${payload.label}`);
41
+ this.log(`ID: ${payload.id}`);
42
+ this.log(`Status: ${payload.status}`);
43
+ this.log(`Roadmap: ${payload.roadmap_status ?? 'None'}`);
44
+ this.log(`Feature ID: ${payload.parent_feature_id ?? payload.parent_id ?? 'None'}`);
45
+ this.log(`Steps: ${payload.step_count}`);
46
+ if (payload.description) {
47
+ this.log(`\nDescription:\n${payload.description}\n`);
48
+ }
49
+ if (this.isInteractiveMode && metadata?.action_hints) {
50
+ await this.handleActionHints(metadata.action_hints);
51
+ }
52
+ }
53
+ }
54
+ catch (err) {
55
+ this.handleError(`Failed to fetch flow detail for ID: ${flags.id}`, err);
56
+ }
57
+ }
58
+ }
@@ -0,0 +1,105 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Flags } from '@oclif/core';
3
+ import { BaseCommand } from '../../base.js';
4
+ import { updateFlow } from '../../generated/api/sdk.gen.js';
5
+ import { zUpdateFlowResponse } from '../../generated/api/zod.gen.js';
6
+ import { validateResponse } from '../../core/api/index.js';
7
+ import { SuccessMessage } from '../../ui/index.js';
8
+ export default class FlowUpdate extends BaseCommand {
9
+ static description = 'Update a flow';
10
+ static examples = [
11
+ '<%= config.bin %> flow:update --id <uuid> --status IN_DEVELOPMENT',
12
+ '<%= config.bin %> flow:update --id <uuid> --status COMPLETE --title "New Title"',
13
+ '<%= config.bin %> flow:update --id <uuid> --roadmap-status next --priority MUST_HAVE',
14
+ ];
15
+ static flags = {
16
+ ...BaseCommand.baseFlags,
17
+ id: Flags.string({
18
+ description: 'Flow ID (UUID)',
19
+ required: true,
20
+ }),
21
+ status: Flags.string({
22
+ description: 'New status (BACKLOG|NOT_STARTED|IN_PLANNING|READY_FOR_DEVELOPMENT|IN_DEVELOPMENT|BLOCKED|REVIEW|COMPLETE)',
23
+ options: ['BACKLOG', 'NOT_STARTED', 'IN_PLANNING', 'READY_FOR_DEVELOPMENT', 'IN_DEVELOPMENT', 'BLOCKED', 'REVIEW', 'COMPLETE'],
24
+ }),
25
+ title: Flags.string({
26
+ description: 'New title',
27
+ }),
28
+ description: Flags.string({
29
+ description: 'New description',
30
+ }),
31
+ priority: Flags.string({
32
+ description: 'New priority (MUST_HAVE|SHOULD_HAVE|BACKLOG)',
33
+ options: ['MUST_HAVE', 'SHOULD_HAVE', 'BACKLOG'],
34
+ }),
35
+ 'roadmap-status': Flags.string({
36
+ description: 'New roadmap status (now|next|later)',
37
+ options: ['now', 'next', 'later'],
38
+ }),
39
+ force: Flags.boolean({
40
+ description: 'Skip confirmation prompt',
41
+ default: false,
42
+ }),
43
+ };
44
+ async run() {
45
+ const { flags } = await this.parse(FlowUpdate);
46
+ const projectId = await this.getProjectIdOrExit();
47
+ await this.renderHeader('ready', 'compact');
48
+ const body = {};
49
+ if (flags.status !== undefined)
50
+ body.status = flags.status;
51
+ if (flags.title !== undefined)
52
+ body.label = flags.title;
53
+ if (flags.description !== undefined)
54
+ body.description = flags.description;
55
+ if (flags.priority !== undefined)
56
+ body.priority = flags.priority;
57
+ if (flags['roadmap-status'] !== undefined)
58
+ body.roadmap_status = flags['roadmap-status'];
59
+ if (Object.keys(body).length === 0) {
60
+ const message = 'At least one field must be provided to update (--status, --title, --description, --priority, --roadmap-status)';
61
+ if (this.isJsonMode) {
62
+ this.outputErrorAndExit({ message, code: 'MISSING_INPUT' });
63
+ }
64
+ else {
65
+ this.error(message);
66
+ }
67
+ }
68
+ const confirmed = await this.confirmAction(`Update flow ${flags.id}?`, flags.force, { headerVariant: 'compact' });
69
+ if (!confirmed)
70
+ return;
71
+ const payload = {
72
+ path: {
73
+ projectId,
74
+ flowId: flags.id,
75
+ },
76
+ body,
77
+ };
78
+ if (this.isDryRun) {
79
+ await this.handleDryRun(payload);
80
+ return;
81
+ }
82
+ const client = await this.getClient();
83
+ const { data, error } = await this.withSpinner(`Updating flow ${flags.id}...`, () => updateFlow({
84
+ client,
85
+ ...payload,
86
+ }));
87
+ try {
88
+ const validated = validateResponse(data, error, zUpdateFlowResponse);
89
+ const flow = validated.data;
90
+ const metadata = validated.metadata;
91
+ if (this.isJsonMode) {
92
+ this.outputJson(flow, { source: 'flow update', ...metadata });
93
+ }
94
+ else {
95
+ await this.renderInk(_jsx(SuccessMessage, { message: `Flow ${flags.id} updated successfully!` }));
96
+ if (this.isInteractiveMode && metadata?.action_hints) {
97
+ await this.handleActionHints(metadata.action_hints);
98
+ }
99
+ }
100
+ }
101
+ catch (err) {
102
+ this.handleError(`Failed to update flow: ${flags.id}`, err);
103
+ }
104
+ }
105
+ }
@@ -74,8 +74,8 @@ export default class IdeaGraduate extends BaseCommand {
74
74
  if (this.isInteractiveMode) {
75
75
  const { default: chalk } = await import('chalk');
76
76
  this.log(`\n 🚀 Next steps:`);
77
- this.log(` - Project info: ${chalk.cyan(`4ge project info --id ${result.id}`)}`);
78
- this.log(` - Pull plan: ${chalk.cyan(`4ge plan pull --project ${result.id}`)}`);
77
+ this.log(` - Project info: ${chalk.cyan(`4ge project:info --id ${result.id}`)}`);
78
+ this.log(` - Pull plan: ${chalk.cyan(`4ge plan:pull --project ${result.id}`)}`);
79
79
  if (metadata?.action_hints) {
80
80
  await this.handleActionHints(metadata.action_hints);
81
81
  }
@@ -326,7 +326,7 @@ export default class Init extends BaseCommand {
326
326
  this.log(`Skill installed at: ${skillPath}`);
327
327
  this.log(chalk.dim('Reload your AI agent to pick up the new skill.'));
328
328
  }
329
- this.log(chalk.dim('\nConfigure additional settings with: 4ge config manage'));
329
+ this.log(chalk.dim('\nConfigure additional settings with: 4ge config:manage'));
330
330
  this.log(chalk.dim('Available: default mode, hierarchy settings, pull settings\n'));
331
331
  }
332
332
  }
@@ -1,5 +1,5 @@
1
1
  import AuthLogin from './auth/login.js';
2
2
  export default class Login extends AuthLogin {
3
- static description = 'Alias for `4ge auth login`';
3
+ static description = 'Alias for `4ge auth:login`';
4
4
  static hidden = false;
5
5
  }
@@ -4,6 +4,6 @@ export default class Project extends BaseCommand {
4
4
  static hidden = true;
5
5
  async run() {
6
6
  await this.renderHeader('ready', 'compact');
7
- this.log('Use `4ge project list` to list projects.');
7
+ this.log('Use `4ge project:list` to list projects.');
8
8
  }
9
9
  }
@@ -62,7 +62,7 @@ export default class ProjectList extends BaseCommand {
62
62
  const selectedId = await this.selectOption('Select a project for details:', options, { headerVariant: 'compact' });
63
63
  if (selectedId) {
64
64
  const { default: chalk } = await import('chalk');
65
- this.log(`\n 🚀 Run: ${chalk.cyan(`4ge project show ${selectedId}`)}\n`);
65
+ this.log(`\n 🚀 Run: ${chalk.cyan(`4ge project:show ${selectedId}`)}\n`);
66
66
  }
67
67
  }
68
68
  if (metadata?.action_hints) {
@@ -65,7 +65,7 @@ export default class StoryList extends BaseCommand {
65
65
  const selectedId = await this.selectOption('Select a story for details:', options, { headerVariant: 'compact' });
66
66
  if (selectedId) {
67
67
  const { default: chalk } = await import('chalk');
68
- this.log(`\n 🚀 Run: ${chalk.cyan(`4ge story show ${selectedId}`)}\n`);
68
+ this.log(`\n 🚀 Run: ${chalk.cyan(`4ge story:show ${selectedId}`)}\n`);
69
69
  }
70
70
  }
71
71
  if (metadata?.action_hints) {
@@ -5,6 +5,8 @@ import { listTemplates } from '../../generated/api/sdk.gen.js';
5
5
  import { zListTemplatesResponse } from '../../generated/api/zod.gen.js';
6
6
  import { validateResponse } from '../../core/api/index.js';
7
7
  import { DataTable } from '../../ui/index.js';
8
+ /** Render a boolean as a checkmark or empty string. Pure — extracted from the DataTable column def so it's unit-testable. */
9
+ export const formatCheckmark = (v) => (v ? '✓' : '');
8
10
  export default class TemplateList extends BaseCommand {
9
11
  static description = 'List all available project templates';
10
12
  static flags = {
@@ -52,7 +54,7 @@ export default class TemplateList extends BaseCommand {
52
54
  { key: 'framework', header: 'Framework' },
53
55
  { key: 'author', header: 'Author' },
54
56
  { key: 'featuresCount', header: 'Features' },
55
- { key: 'isOfficial', header: 'Official', format: (v) => (v ? '✓' : '') },
57
+ { key: 'isOfficial', header: 'Official', format: formatCheckmark },
56
58
  ], title: "Available Templates", emptyMessage: flags.workspace ? `No templates found for workspace: ${flags.workspace}` : 'No templates found.' }));
57
59
  if (this.isInteractiveMode) {
58
60
  if (filteredTemplates.length > 0) {
@@ -64,7 +66,7 @@ export default class TemplateList extends BaseCommand {
64
66
  const selectedId = await this.selectOption('Select a template for details:', options, { headerVariant: 'compact' });
65
67
  if (selectedId) {
66
68
  const { default: chalk } = await import('chalk');
67
- this.log(`\n 🚀 Run: ${chalk.cyan(`4ge template info --id ${selectedId}`)}\n`);
69
+ this.log(`\n 🚀 Run: ${chalk.cyan(`4ge template:info --id ${selectedId}`)}\n`);
68
70
  }
69
71
  }
70
72
  if (metadata?.action_hints) {
@@ -87,8 +87,8 @@ export default class TemplatePull extends BaseCommand {
87
87
  }
88
88
  if (this.isInteractiveMode) {
89
89
  await this.handleActionHints([
90
- { label: 'List template features', command: `4ge template feature list ${templateId}` },
91
- { label: 'Project info', command: '4ge project info' },
90
+ { label: 'List template features', command: `4ge template:feature:list ${templateId}` },
91
+ { label: 'Project info', command: '4ge project:info' },
92
92
  ]);
93
93
  }
94
94
  }
@@ -1,5 +1,5 @@
1
1
  import AuthStatus from './auth/status.js';
2
2
  export default class Whoami extends AuthStatus {
3
- static description = 'Alias for `4ge auth status`';
3
+ static description = 'Alias for `4ge auth:status`';
4
4
  static hidden = false;
5
5
  }
@@ -16,7 +16,7 @@ export function isAuthError(error) {
16
16
  */
17
17
  export function getAuthErrorHint(error) {
18
18
  if (error?.code === 'TOKEN_EXPIRED') {
19
- return "Run '4ge auth login' to re-authenticate";
19
+ return "Run '4ge auth:login' to re-authenticate";
20
20
  }
21
21
  if (error?.code === 'INVALID_API_KEY') {
22
22
  return 'Check your 4GE_API_KEY environment variable';
@@ -146,7 +146,7 @@ export function createApiClient(opts) {
146
146
  code: isPatAuth ? 'INVALID_API_KEY' : 'TOKEN_EXPIRED',
147
147
  hint: isPatAuth
148
148
  ? 'Check your 4GE_API_KEY environment variable'
149
- : "Run '4ge auth login' to re-authenticate",
149
+ : "Run '4ge auth:login' to re-authenticate",
150
150
  };
151
151
  return authError;
152
152
  }
@@ -66,7 +66,7 @@ export async function ensureFreshToken() {
66
66
  }
67
67
  if (result.error || !result.data) {
68
68
  // Server is reachable and explicitly rejected the refresh token.
69
- throw new Error(`Session expired. Please re-authenticate: run '4ge auth login'\n` +
69
+ throw new Error(`Session expired. Please re-authenticate: run '4ge auth:login'\n` +
70
70
  `Details: ${result.error?.message || 'Refresh token is invalid or expired'}`);
71
71
  }
72
72
  try {
@@ -33,5 +33,5 @@ export function generateReadmeMd(project, filesGenerated) {
33
33
  ${fileLinks}
34
34
 
35
35
  ---
36
- *Generated by \`4ge project pull\` on ${date}*`;
36
+ *Generated by \`4ge project:pull\` on ${date}*`;
37
37
  }
@@ -1,2 +1,2 @@
1
1
  // This file is auto-generated by @hey-api/openapi-ts
2
- export { cliAuthCallback, cliAuthEmailLogin, cliAuthLogin, cliAuthRefresh, cliAuthStatus, createIdea, createProject, createTemplate, createTemplateFeature, deleteTemplate, deleteTemplateFeature, getEpicDetail, getEpicTree, getFeatureDetail, getFeatureTree, getLatestPlanVersion, getPlanMetadata, getPlanTree, getPlanVersionDetail, getPlanVersions, getProjectDetail, getStoryDetail, getStoryTree, getTemplateDetail, graduateIdea, listEpics, listFeatures, listIdeas, listProjects, listStories, listTemplateFeatures, listTemplates, listWorkspaces, updateEpic, updateFeature, updateIdea, updateProject, updateStory, updateTemplate, updateTemplateFeature, } from "./sdk.gen.js";
2
+ export { cliAuthCallback, cliAuthEmailLogin, cliAuthLogin, cliAuthRefresh, cliAuthStatus, createIdea, createProject, createTemplate, createTemplateFeature, deleteTemplate, deleteTemplateFeature, getEpicDetail, getEpicTree, getFeatureDetail, getFeatureTree, getFlow, getFlowTree, getLatestPlanVersion, getPlanMetadata, getPlanTree, getPlanVersionDetail, getPlanVersions, getProjectDetail, getStoryDetail, getStoryTree, getTemplateDetail, graduateIdea, listEpics, listFeatures, listFlows, listIdeas, listProjects, listStories, listTemplateFeatures, listTemplates, listWorkspaces, updateEpic, updateFeature, updateFlow, updateIdea, updateProject, updateStory, updateTemplate, updateTemplateFeature, } from "./sdk.gen.js";
@@ -301,6 +301,62 @@ export const getFeatureTree = (options) => (options.client ?? client).get({
301
301
  url: "/api/cli/v1/projects/{projectId}/features/{featureId}/tree",
302
302
  ...options,
303
303
  });
304
+ /**
305
+ * list-flows
306
+ *
307
+ * List flows under a feature. A feature owns one implied flow (its ordered useraction/systemresponse/conditional steps), so this returns a 1-element array. roadmap/status filters apply at the feature level.
308
+ */
309
+ export const listFlows = (options) => (options.client ?? client).get({
310
+ security: [
311
+ { scheme: "bearer", type: "http" },
312
+ { name: "Authorization", type: "apiKey" },
313
+ ],
314
+ url: "/api/cli/v1/projects/{projectId}/features/{featureId}/flows",
315
+ ...options,
316
+ });
317
+ /**
318
+ * get-flow
319
+ *
320
+ * Get a flow summary. flowId is the owning feature id (a flow is the implied collection of steps attached to a feature via parent_feature_id).
321
+ */
322
+ export const getFlow = (options) => (options.client ?? client).get({
323
+ security: [
324
+ { scheme: "bearer", type: "http" },
325
+ { name: "Authorization", type: "apiKey" },
326
+ ],
327
+ url: "/api/cli/v1/projects/{projectId}/flows/{flowId}",
328
+ ...options,
329
+ });
330
+ /**
331
+ * update-flow
332
+ *
333
+ * Update a flow. flowId is the owning feature id; flow-level metadata IS the feature metadata (status/label/description/roadmap/priority). Invalid enums return 422.
334
+ */
335
+ export const updateFlow = (options) => (options.client ?? client).patch({
336
+ security: [
337
+ { scheme: "bearer", type: "http" },
338
+ { name: "Authorization", type: "apiKey" },
339
+ ],
340
+ url: "/api/cli/v1/projects/{projectId}/flows/{flowId}",
341
+ ...options,
342
+ headers: {
343
+ "Content-Type": "application/json",
344
+ ...options.headers,
345
+ },
346
+ });
347
+ /**
348
+ * get-flow-tree
349
+ *
350
+ * Get a flow with its ordered step tree. Steps are topologically sorted over project_plan_edges; conditional branches nest via parent_id.
351
+ */
352
+ export const getFlowTree = (options) => (options.client ?? client).get({
353
+ security: [
354
+ { scheme: "bearer", type: "http" },
355
+ { name: "Authorization", type: "apiKey" },
356
+ ],
357
+ url: "/api/cli/v1/projects/{projectId}/flows/{flowId}/tree",
358
+ ...options,
359
+ });
304
360
  /**
305
361
  * list-stories
306
362
  *
@@ -748,6 +748,173 @@ export const zGetFeatureTreeResponse = z.object({
748
748
  action_hints: z.array(z.unknown()).optional(),
749
749
  }),
750
750
  });
751
+ export const zListFlowsPath = z.object({
752
+ projectId: z.uuid(),
753
+ featureId: z.uuid(),
754
+ });
755
+ export const zListFlowsQuery = z.object({
756
+ roadmap: z.enum(["now", "next", "later"]).optional(),
757
+ status: z.string().optional(),
758
+ });
759
+ /**
760
+ * Successful response
761
+ */
762
+ export const zListFlowsResponse = z.object({
763
+ data: z.array(z.object({
764
+ id: z.uuid(),
765
+ label: z.string(),
766
+ description: z.string().nullable(),
767
+ node_type: z.enum(["flow"]),
768
+ status: z.enum([
769
+ "BACKLOG",
770
+ "NOT_STARTED",
771
+ "IN_PLANNING",
772
+ "READY_FOR_DEVELOPMENT",
773
+ "IN_DEVELOPMENT",
774
+ "BLOCKED",
775
+ "REVIEW",
776
+ "COMPLETE",
777
+ ]),
778
+ priority: z.string().nullable(),
779
+ roadmap_status: z
780
+ .enum(["now", "next", "later", "someday", "released"])
781
+ .optional(),
782
+ parent_id: z.uuid().nullable(),
783
+ parent_feature_id: z.uuid().nullish(),
784
+ project_id: z.uuid(),
785
+ workspace_id: z.uuid(),
786
+ position_x: z.number().optional(),
787
+ position_y: z.number().optional(),
788
+ step_count: z.int().gte(0),
789
+ })),
790
+ error: z.unknown(),
791
+ metadata: z.object({
792
+ timestamp: z.string(),
793
+ request_id: z.string(),
794
+ action_hints: z.array(z.unknown()).optional(),
795
+ }),
796
+ });
797
+ export const zGetFlowPath = z.object({
798
+ projectId: z.uuid(),
799
+ flowId: z.uuid(),
800
+ });
801
+ /**
802
+ * Successful response
803
+ */
804
+ export const zGetFlowResponse = z.object({
805
+ data: z.object({
806
+ id: z.uuid(),
807
+ label: z.string(),
808
+ description: z.string().nullable(),
809
+ node_type: z.enum(["flow"]),
810
+ status: z.enum([
811
+ "BACKLOG",
812
+ "NOT_STARTED",
813
+ "IN_PLANNING",
814
+ "READY_FOR_DEVELOPMENT",
815
+ "IN_DEVELOPMENT",
816
+ "BLOCKED",
817
+ "REVIEW",
818
+ "COMPLETE",
819
+ ]),
820
+ priority: z.string().nullable(),
821
+ roadmap_status: z
822
+ .enum(["now", "next", "later", "someday", "released"])
823
+ .optional(),
824
+ parent_id: z.uuid().nullable(),
825
+ parent_feature_id: z.uuid().nullish(),
826
+ project_id: z.uuid(),
827
+ workspace_id: z.uuid(),
828
+ position_x: z.number().optional(),
829
+ position_y: z.number().optional(),
830
+ step_count: z.int().gte(0),
831
+ }),
832
+ error: z.unknown(),
833
+ metadata: z.object({
834
+ timestamp: z.string(),
835
+ request_id: z.string(),
836
+ action_hints: z.array(z.unknown()).optional(),
837
+ }),
838
+ });
839
+ export const zUpdateFlowBody = z.object({
840
+ label: z.string().min(1).optional(),
841
+ description: z.string().optional(),
842
+ status: z
843
+ .enum([
844
+ "BACKLOG",
845
+ "NOT_STARTED",
846
+ "IN_PLANNING",
847
+ "READY_FOR_DEVELOPMENT",
848
+ "IN_DEVELOPMENT",
849
+ "BLOCKED",
850
+ "REVIEW",
851
+ "COMPLETE",
852
+ ])
853
+ .optional(),
854
+ priority: z.enum(["MUST_HAVE", "SHOULD_HAVE", "BACKLOG"]).optional(),
855
+ roadmap_status: z
856
+ .enum(["now", "next", "later", "someday", "released"])
857
+ .optional(),
858
+ parent_id: z.uuid().optional(),
859
+ });
860
+ export const zUpdateFlowPath = z.object({
861
+ projectId: z.uuid(),
862
+ flowId: z.uuid(),
863
+ });
864
+ /**
865
+ * Successful response
866
+ */
867
+ export const zUpdateFlowResponse = z.object({
868
+ data: z.object({
869
+ id: z.uuid(),
870
+ label: z.string(),
871
+ description: z.string().nullable(),
872
+ node_type: z.enum(["flow"]),
873
+ status: z.enum([
874
+ "BACKLOG",
875
+ "NOT_STARTED",
876
+ "IN_PLANNING",
877
+ "READY_FOR_DEVELOPMENT",
878
+ "IN_DEVELOPMENT",
879
+ "BLOCKED",
880
+ "REVIEW",
881
+ "COMPLETE",
882
+ ]),
883
+ priority: z.string().nullable(),
884
+ roadmap_status: z
885
+ .enum(["now", "next", "later", "someday", "released"])
886
+ .optional(),
887
+ parent_id: z.uuid().nullable(),
888
+ parent_feature_id: z.uuid().nullish(),
889
+ project_id: z.uuid(),
890
+ workspace_id: z.uuid(),
891
+ position_x: z.number().optional(),
892
+ position_y: z.number().optional(),
893
+ step_count: z.int().gte(0),
894
+ }),
895
+ error: z.unknown(),
896
+ metadata: z.object({
897
+ timestamp: z.string(),
898
+ request_id: z.string(),
899
+ action_hints: z.array(z.unknown()).optional(),
900
+ }),
901
+ });
902
+ export const zGetFlowTreePath = z.object({
903
+ projectId: z.uuid(),
904
+ flowId: z.uuid(),
905
+ });
906
+ /**
907
+ * Successful response
908
+ */
909
+ export const zGetFlowTreeResponse = z.object({
910
+ data: z.unknown().optional(),
911
+ error: z.unknown(),
912
+ metadata: z.object({
913
+ timestamp: z.string(),
914
+ request_id: z.string(),
915
+ action_hints: z.array(z.unknown()).optional(),
916
+ }),
917
+ });
751
918
  export const zListStoriesPath = z.object({
752
919
  projectId: z.uuid(),
753
920
  });
@@ -23,8 +23,9 @@ const COMPACT_ART = [
23
23
  ];
24
24
  const START_COLOR = { r: 0, g: 229, b: 255 }; // #00e5ff
25
25
  const END_COLOR = { r: 224, g: 64, b: 251 }; // #e040fb
26
- const lerp = (start, end, t) => Math.round(start + (end - start) * t);
27
- const applyGradient = (text, width) => {
26
+ export const lerp = (start, end, t) => Math.round(start + (end - start) * t);
27
+ /** Apply a linear RGB gradient over a single-line string. Pure. */
28
+ export const applyGradient = (text, width) => {
28
29
  return text
29
30
  .split('')
30
31
  .map((char, i) => {
@@ -37,14 +38,16 @@ const applyGradient = (text, width) => {
37
38
  .join('');
38
39
  };
39
40
  const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
40
- const STATUS_CONFIG = {
41
+ export const STATUS_CONFIG = {
41
42
  'checking-connectivity': { label: 'Checking Connectivity', icon: SPINNER_FRAMES, color: 'yellow' },
42
43
  'refreshing-token': { label: 'Refreshing Token', icon: SPINNER_FRAMES, color: 'yellow' },
43
44
  ready: { label: 'Ready', icon: '✓', color: 'green' },
44
45
  'no-connection': { label: 'No Connection', icon: '✗', color: 'red' },
45
46
  'update-required': { label: 'Update Required', icon: '⚠', color: 'magenta' },
46
47
  };
47
- const getArt = (variant) => {
48
+ /** Resolve the status config (label/icon/color) for a CliStatus. Pure. */
49
+ export const getStatusConfig = (status) => STATUS_CONFIG[status];
50
+ export const getArt = (variant) => {
48
51
  const art = variant === 'compact' ? MIDDY_ART : ART;
49
52
  const width = Math.max(...art.map((line) => line.length));
50
53
  return { art, width };
@@ -59,7 +62,7 @@ export function renderHeaderString(version, status = 'ready', headerVariant = 'f
59
62
  return `${chalk.dim('║ ')}${applyGradient(line + padding, width)}${chalk.dim(' ║')}`;
60
63
  })
61
64
  .join('\n');
62
- const currentStatus = STATUS_CONFIG[status];
65
+ const currentStatus = getStatusConfig(status);
63
66
  const icon = Array.isArray(currentStatus.icon) ? currentStatus.icon[0] : currentStatus.icon;
64
67
  const statusLabel = chalk[currentStatus.color](`${currentStatus.label} ${icon}`);
65
68
  const statusBar = chalk.dim(`[ 4ge CLI | v${version} | Status: ${statusLabel} ]`);
@@ -82,7 +85,7 @@ export default function CliHeader({ version, status = 'ready', headerVariant = '
82
85
  const { art, width } = getArt(headerVariant);
83
86
  const borderTop = `╔${'═'.repeat(width + 2)}╗`;
84
87
  const borderBottom = `╚${'═'.repeat(width + 2)}╝`;
85
- const currentStatus = STATUS_CONFIG[status];
88
+ const currentStatus = getStatusConfig(status);
86
89
  const icon = Array.isArray(currentStatus.icon) ? currentStatus.icon[frame] : currentStatus.icon;
87
90
  return (_jsxs(Box, { flexDirection: "column", marginY: 1, children: [_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: borderTop }), art.map((line, i) => {
88
91
  const padding = ' '.repeat(width - line.length);
@@ -1,6 +1,11 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
- const getStatusColor = (value) => {
3
+ /**
4
+ * Map a status value to a chalk/ink color token.
5
+ * Pure: given a value, returns 'green' | 'yellow' | 'red' | undefined.
6
+ * Exported for unit testing — the mapping is logic, the JSX is wiring.
7
+ */
8
+ export const getStatusColor = (value) => {
4
9
  if (typeof value !== 'string')
5
10
  return undefined;
6
11
  const upper = value.toUpperCase();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@4ge/cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "4ge Service Architecture Bridge & CLI",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -38,6 +38,7 @@
38
38
  "dev": "node --env-file=.env.local ./bin/dev.js",
39
39
  "test": "node --env-file=.env.test ./node_modules/vitest/vitest.mjs run",
40
40
  "test:watch": "node --env-file=.env.test ./node_modules/vitest/vitest.mjs",
41
+ "test:coverage": "node --env-file=.env.test ./node_modules/vitest/vitest.mjs run --coverage",
41
42
  "lint": "eslint src/ --ext .ts",
42
43
  "format": "prettier --write \"src/**/*.ts\"",
43
44
  "sync-api": "openapi-ts && prettier --write \"src/generated/api/**/*.ts\"",
package/skills/4ge-cli.md CHANGED
@@ -13,15 +13,30 @@ Before using this CLI, the user **MUST** have completed:
13
13
  The CLI is already installed and configured in this project.
14
14
 
15
15
  ## ⌨️ Command Syntax
16
- All interactions follow this structure:
16
+
17
+ **Convention (hard rule):** commands are `4ge <namespace>:<command> [--params]` —
18
+ a colon between namespace and command, **never a space**.
19
+
20
+ | Correct | Wrong |
21
+ |---|---|
22
+ | `4ge epic:list` | ~~`4ge epic list`~~ |
23
+ | `4ge project:pull --json` | ~~`4ge project pull --json`~~ |
24
+ | `4ge flow:show --id <id>` | ~~`4ge flow show --id <id>`~~ |
25
+ | `4ge auth:status` | ~~`4ge auth status`~~ |
26
+
27
+ Nested commands chain with more colons: `4ge template:feature:list`.
28
+ A few root commands take no colon (`4ge init`, `4ge whoami`, `4ge login`) —
29
+ single words with no nested command.
30
+
31
+ Full form:
17
32
 
18
33
  ```bash
19
34
  4ge <namespace>:<command> [options] --json
20
35
  ```
21
36
 
22
- - **Namespace:Command**: Colon-separated (e.g. `epic:list`, `story:show`, `auth:status`).
23
- - **Required flag**: `--json` is **mandatory** in agent mode for machine-readable output.
24
- - **Positional arguments**: A few commands take a direct arg without a flag (e.g. `idea:show <ID>`).
37
+ - **`--json` is mandatory** in agent mode for machine-readable output.
38
+ - **Positional arguments**: a few commands take a direct arg without a flag
39
+ (e.g. `4ge idea:show <ID>`).
25
40
 
26
41
  ## 🛠 Usage Guidelines
27
42
 
@@ -80,6 +95,7 @@ Use it for network-flaky operations.
80
95
  |-----------|----------|-----------|
81
96
  | **project** | `create`, `info`, `list`, `pull`, `update` | `--workspace` (create), `--name`, `--description`, `--template`, `--force`, `--save`/`--stdout` (pull), `--dry-run`, `--retry` |
82
97
  | **epic** | `list`, `show`, `pull`, `update` | `--epic-id` / `--id` (pull), `--status`, `--title`, `--recursive` (pull), `--roadmap` (pull, with `--recursive`), `--save`/`--stdout` (pull), `--dry-run`, `--retry` |
98
+ | **flow** | `list`, `show`, `pull`, `update` | `--id`, `--feature-id` (list), `--recursive` (pull — full flow step tree), `--status`, `--title` (update), `--description` (update), `--priority` (update), `--roadmap-status` (update), `--save`/`--stdout` (pull), `--dry-run`, `--retry` |
83
99
  | **story** | `list`, `show`, `pull`, `update` | `--story-id` / `--id` (pull), `--status`, `--title`, `--recursive` (pull), `--roadmap` (pull, with `--recursive`), `--save`/`--stdout` (pull), `--dry-run`, `--retry` |
84
100
  | **feature** | `list`, `show`, `pull`, `update` | `--feature-id` / `--id` (pull), `--status`, `--roadmap`, `--recursive` (pull), `--roadmap` (pull, with `--recursive`), `--save`/`--stdout` (pull), `--dry-run`, `--retry` |
85
101
  | **idea** | `create`, `graduate`, `list`, `show`, `update` | `<ID>` (positional for show), `--title`, `--workspace`, `--dry-run`, `--retry` |
@@ -102,6 +118,76 @@ No `--project-id` flag is needed for those namespaces.
102
118
 
103
119
  Flags always override config; bare `4ge <ns>:pull --id <id>` honours the config defaults.
104
120
 
121
+ ### Plan data model — parent-link semantics
122
+
123
+ **Read this before reconstructing a hierarchy from `list`/`pull --json` output.
124
+ A wrong field will falsely make everything look orphaned.**
125
+
126
+ Each project plan node (epic / story / feature / flow step) carries TWO
127
+ parent-link fields. They coexist by design — neither is legacy — and they
128
+ serve different relationships:
129
+
130
+ | Field | Meaning | Populated on |
131
+ |---|---|---|
132
+ | `parent_id` | The **hierarchy tree** edge. epic → story → feature, plus step → step containment inside flows. | epics, stories, features, flow steps |
133
+ | `parent_feature_id` | A **flow-ownership** edge. Used ONLY by flow-step nodes (`node_type`: `useraction` / `systemresponse` / `conditional`) to attach to their owning feature. These steps deliberately have `parent_id` null (or pointing at another step). | flow steps only |
134
+
135
+ **Canonical hierarchy is `epic → story → feature`** (parent points UP
136
+ toward the epic), and the tree read bottom-up is:
137
+
138
+ ```
139
+ feature.parent_id → a story id → (story.parent_id) → an epic
140
+ ```
141
+
142
+ Not feature → epic directly — the story is the mandatory intermediate node.
143
+
144
+ #### Rules agents must follow
145
+
146
+ 1. **For features and stories, `parent_feature_id` is null by design.**
147
+ Features and stories are never flow steps, so they use `parent_id`
148
+ exclusively. A null `parent_feature_id` on a feature/story is NOT a
149
+ broken link and NOT an orphan. Aggregating `parent_feature_id` to
150
+ reconstruct the tree will falsely report "orphans everywhere" — do
151
+ not do this.
152
+
153
+ 2. **To reconstruct the hierarchy from list output, use `parent_id`,
154
+ never `parent_feature_id`:**
155
+ - `feature.parent_id` → a story id (the story that owns the feature).
156
+ - `story.parent_id` → an epic id.
157
+ - Group features by epic: resolve `feature.parent_id` → story, then
158
+ `story.parent_id` → epic.
159
+
160
+ 3. **`children: []` under a filtered query on a flow-bearing feature is
161
+ a known, narrow bug — not proof of corruption.** Under an active
162
+ roadmap/status/priority filter, flow-step nodes linked ONLY via
163
+ `parent_feature_id` (no usable `parent_id`) can be pruned in the tree
164
+ builder and surface as `children: []` on their owning feature. This
165
+ affects **flow steps only** — never the `parent_id` epic→story→feature
166
+ backbone. If you see empty `children` only under a filtered query on a
167
+ feature that bears flows, cite this caveat before claiming the
168
+ hierarchy is broken.
169
+
170
+ *Contract:* the CLI surfaces the server's tree as-is. There is no CLI
171
+ flag to work around the flow-step pruning case; if you hit it, query
172
+ without the filter, or pull the flow's own step tree instead:
173
+ `flow:list --feature-id <featureId> --json` to resolve the flow id, then
174
+ `flow:pull --id <flowId> --recursive --json` (the flow step tree is not
175
+ subject to the same pruning).
176
+
177
+ #### Quick check: is the hierarchy intact?
178
+
179
+ ```bash
180
+ # Every feature's parent_id should resolve to a story id in story:list.
181
+ 4ge feature:list --json # collect feature[*].parent_id
182
+ 4ge story:list --json # every parent_id from above must appear here
183
+ # Every story's parent_id should resolve to an epic id in epic:list.
184
+ 4ge story:list --json # collect story[*].parent_id
185
+ 4ge epic:list --json # every parent_id from above must appear here
186
+ ```
187
+
188
+ If those joins are complete, the hierarchy is intact regardless of how
189
+ many `parent_feature_id` fields are null.
190
+
105
191
  ## 🔄 Common Workflow Patterns
106
192
 
107
193
  1. **Explore projects**: `4ge project:list --json`