soja-cli 1.4.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.
Files changed (189) hide show
  1. package/LICENSE +18 -0
  2. package/README.md +117 -0
  3. package/dist/application/chat.js +1 -0
  4. package/dist/application/filters.js +27 -0
  5. package/dist/application/import.js +1 -0
  6. package/dist/application/ports.js +1 -0
  7. package/dist/application/repository-root.js +18 -0
  8. package/dist/application/services/backup-service.js +93 -0
  9. package/dist/application/services/folder-service.js +116 -0
  10. package/dist/application/services/git-workflow-service.js +593 -0
  11. package/dist/application/services/index.js +24 -0
  12. package/dist/application/services/project-service.js +129 -0
  13. package/dist/application/services/session-service.js +52 -0
  14. package/dist/application/services/task-service.js +350 -0
  15. package/dist/application/services/update-service.js +60 -0
  16. package/dist/application/services/workspace-service.js +81 -0
  17. package/dist/application/timeline.js +83 -0
  18. package/dist/application/types.js +1 -0
  19. package/dist/application/validation.js +30 -0
  20. package/dist/bootstrap.js +91 -0
  21. package/dist/cli/commands/access.js +60 -0
  22. package/dist/cli/commands/account.js +203 -0
  23. package/dist/cli/commands/args.js +27 -0
  24. package/dist/cli/commands/backup.js +70 -0
  25. package/dist/cli/commands/chat.js +133 -0
  26. package/dist/cli/commands/dev.js +59 -0
  27. package/dist/cli/commands/folders.js +59 -0
  28. package/dist/cli/commands/git-ops.js +200 -0
  29. package/dist/cli/commands/import.js +48 -0
  30. package/dist/cli/commands/info.js +82 -0
  31. package/dist/cli/commands/project.js +108 -0
  32. package/dist/cli/commands/start.js +34 -0
  33. package/dist/cli/commands/sync.js +43 -0
  34. package/dist/cli/commands/task.js +309 -0
  35. package/dist/cli/commands/tui.js +43 -0
  36. package/dist/cli/commands/update.js +20 -0
  37. package/dist/cli/commands/workspace.js +55 -0
  38. package/dist/cli/index.js +103 -0
  39. package/dist/cli/output.js +56 -0
  40. package/dist/cli/prompt.js +28 -0
  41. package/dist/cli/runtime.js +43 -0
  42. package/dist/config/config.js +103 -0
  43. package/dist/config/credentials.js +68 -0
  44. package/dist/config/paths.js +18 -0
  45. package/dist/config/server.js +21 -0
  46. package/dist/config/version.js +7 -0
  47. package/dist/data/local/activity.js +33 -0
  48. package/dist/data/local/clock.js +1 -0
  49. package/dist/data/local/comments.js +27 -0
  50. package/dist/data/local/index.js +48 -0
  51. package/dist/data/local/projects.js +53 -0
  52. package/dist/data/local/tasks.js +104 -0
  53. package/dist/data/local/users.js +38 -0
  54. package/dist/data/local/workspaces.js +55 -0
  55. package/dist/data/remote/api-client.js +103 -0
  56. package/dist/data/remote/live.js +93 -0
  57. package/dist/data/repositories.js +1 -0
  58. package/dist/data/sync/chat.js +242 -0
  59. package/dist/data/sync/engine.js +459 -0
  60. package/dist/data/sync/importer.js +156 -0
  61. package/dist/data/sync/index.js +109 -0
  62. package/dist/data/sync/services.js +263 -0
  63. package/dist/data/sync/store.js +200 -0
  64. package/dist/database/client.js +76 -0
  65. package/dist/database/migrate.js +27 -0
  66. package/dist/database/migrations/0000_init.sql +100 -0
  67. package/dist/database/migrations/0001_git-operations.sql +19 -0
  68. package/dist/database/migrations/0002_merge-detection.sql +19 -0
  69. package/dist/database/migrations/0003_sync-replica.sql +40 -0
  70. package/dist/database/migrations/0004_chat-replica.sql +52 -0
  71. package/dist/database/migrations/0005_pr-events.sql +18 -0
  72. package/dist/database/migrations/0006_archive.sql +20 -0
  73. package/dist/database/migrations/meta/0000_snapshot.json +710 -0
  74. package/dist/database/migrations/meta/0001_snapshot.json +717 -0
  75. package/dist/database/migrations/meta/0002_snapshot.json +724 -0
  76. package/dist/database/migrations/meta/0003_snapshot.json +972 -0
  77. package/dist/database/migrations/meta/0004_snapshot.json +1168 -0
  78. package/dist/database/migrations/meta/0005_snapshot.json +1168 -0
  79. package/dist/database/migrations/meta/0006_snapshot.json +1183 -0
  80. package/dist/database/migrations/meta/_journal.json +55 -0
  81. package/dist/database/schema.js +170 -0
  82. package/dist/dev/seed.js +84 -0
  83. package/dist/domain/activity.js +22 -0
  84. package/dist/domain/chat.js +21 -0
  85. package/dist/domain/entities.js +1 -0
  86. package/dist/domain/errors.js +23 -0
  87. package/dist/domain/naming.js +33 -0
  88. package/dist/domain/task.js +140 -0
  89. package/dist/domain/workflow.js +23 -0
  90. package/dist/git/cli-git.js +363 -0
  91. package/dist/git/console.js +37 -0
  92. package/dist/git/diagnose.js +145 -0
  93. package/dist/git/merge-evidence.js +23 -0
  94. package/dist/git/releases.js +95 -0
  95. package/dist/git/types.js +16 -0
  96. package/dist/ui/App.js +71 -0
  97. package/dist/ui/Shell.js +211 -0
  98. package/dist/ui/app-state.js +213 -0
  99. package/dist/ui/branding/CompactLogo.js +8 -0
  100. package/dist/ui/branding/Logo.js +22 -0
  101. package/dist/ui/branding/Splash.js +21 -0
  102. package/dist/ui/branding/brand.js +23 -0
  103. package/dist/ui/chrome/Sidebar.js +50 -0
  104. package/dist/ui/chrome/StatusBar.js +26 -0
  105. package/dist/ui/chrome/Toast.js +11 -0
  106. package/dist/ui/chrome/TopBar.js +10 -0
  107. package/dist/ui/chrome/context.js +33 -0
  108. package/dist/ui/components/ConsoleLines.js +16 -0
  109. package/dist/ui/components/EmptyState.js +8 -0
  110. package/dist/ui/components/Labels.js +25 -0
  111. package/dist/ui/components/PullRequestLabel.js +27 -0
  112. package/dist/ui/components/TaskTable.js +41 -0
  113. package/dist/ui/components/TextField.js +11 -0
  114. package/dist/ui/components/TextInput.js +29 -0
  115. package/dist/ui/components/task-columns.js +27 -0
  116. package/dist/ui/copy.js +23 -0
  117. package/dist/ui/hooks/use-commands.js +58 -0
  118. package/dist/ui/hooks/use-flows.js +254 -0
  119. package/dist/ui/hooks/use-layout.js +36 -0
  120. package/dist/ui/hooks/use-list.js +28 -0
  121. package/dist/ui/hooks/use-measure.js +22 -0
  122. package/dist/ui/hooks/use-query.js +29 -0
  123. package/dist/ui/hooks/use-task-actions.js +450 -0
  124. package/dist/ui/hooks/use-terminal-size.js +19 -0
  125. package/dist/ui/input/KeyProvider.js +101 -0
  126. package/dist/ui/input/dispatcher.js +26 -0
  127. package/dist/ui/input/list-navigation.js +38 -0
  128. package/dist/ui/input/mouse.js +78 -0
  129. package/dist/ui/input/text-editing.js +62 -0
  130. package/dist/ui/kit/Badge.js +8 -0
  131. package/dist/ui/kit/Button.js +13 -0
  132. package/dist/ui/kit/Clickable.js +14 -0
  133. package/dist/ui/kit/Gauge.js +9 -0
  134. package/dist/ui/kit/Keycap.js +7 -0
  135. package/dist/ui/kit/Panel.js +30 -0
  136. package/dist/ui/kit/ScrollBar.js +14 -0
  137. package/dist/ui/kit/Spinner.js +9 -0
  138. package/dist/ui/kit/Tabs.js +12 -0
  139. package/dist/ui/mascot/AgentScene.js +30 -0
  140. package/dist/ui/mascot/Mascot.js +26 -0
  141. package/dist/ui/mascot/ansi.js +25 -0
  142. package/dist/ui/mascot/engine.js +146 -0
  143. package/dist/ui/mascot/index.js +6 -0
  144. package/dist/ui/mascot/palette.js +18 -0
  145. package/dist/ui/mascot/scenes/bugHunt.js +115 -0
  146. package/dist/ui/mascot/scenes/celebrate.js +76 -0
  147. package/dist/ui/mascot/scenes/cleaning.js +156 -0
  148. package/dist/ui/mascot/scenes/clockIn.js +105 -0
  149. package/dist/ui/mascot/scenes/deadline.js +125 -0
  150. package/dist/ui/mascot/scenes/empty.js +149 -0
  151. package/dist/ui/mascot/scenes/fire.js +138 -0
  152. package/dist/ui/mascot/scenes/index.js +25 -0
  153. package/dist/ui/mascot/scenes/planning.js +73 -0
  154. package/dist/ui/mascot/scenes/resting.js +90 -0
  155. package/dist/ui/mascot/scenes/syncing.js +116 -0
  156. package/dist/ui/mascot/scenes/working.js +193 -0
  157. package/dist/ui/mascot/selection.js +32 -0
  158. package/dist/ui/mascot/states.js +25 -0
  159. package/dist/ui/navigation/routes.js +17 -0
  160. package/dist/ui/overlays/CommitOverlay.js +93 -0
  161. package/dist/ui/overlays/ConfirmOverlay.js +42 -0
  162. package/dist/ui/overlays/GitLogOverlay.js +22 -0
  163. package/dist/ui/overlays/GitRunOverlay.js +94 -0
  164. package/dist/ui/overlays/NewTaskOverlay.js +117 -0
  165. package/dist/ui/overlays/OverlayFrame.js +35 -0
  166. package/dist/ui/overlays/PickerOverlay.js +72 -0
  167. package/dist/ui/overlays/PromptOverlay.js +51 -0
  168. package/dist/ui/overlays/SearchOverlay.js +49 -0
  169. package/dist/ui/overlays/options.js +29 -0
  170. package/dist/ui/overlays/types.js +1 -0
  171. package/dist/ui/screens/AccessScreen.js +91 -0
  172. package/dist/ui/screens/ChatScreen.js +450 -0
  173. package/dist/ui/screens/HelpScreen.js +99 -0
  174. package/dist/ui/screens/ProjectsScreen.js +66 -0
  175. package/dist/ui/screens/ScreenFrame.js +14 -0
  176. package/dist/ui/screens/SetupScreen.js +79 -0
  177. package/dist/ui/screens/TaskListScreen.js +152 -0
  178. package/dist/ui/screens/TaskScreen.js +193 -0
  179. package/dist/ui/screens/WelcomeScreen.js +254 -0
  180. package/dist/ui/screens/WorkspacesScreen.js +64 -0
  181. package/dist/ui/theme/detect.js +59 -0
  182. package/dist/ui/theme/theme.js +135 -0
  183. package/dist/utils/editor.js +37 -0
  184. package/dist/utils/errors.js +23 -0
  185. package/dist/utils/text.js +60 -0
  186. package/dist/utils/time.js +25 -0
  187. package/docs/NPM_PUBLISH.md +35 -0
  188. package/docs/PRIVACY.md +38 -0
  189. package/package.json +74 -0
@@ -0,0 +1,55 @@
1
+ {
2
+ "version": "7",
3
+ "dialect": "sqlite",
4
+ "entries": [
5
+ {
6
+ "idx": 0,
7
+ "version": "6",
8
+ "when": 1790262721308,
9
+ "tag": "0000_init",
10
+ "breakpoints": true
11
+ },
12
+ {
13
+ "idx": 1,
14
+ "version": "6",
15
+ "when": 1790269842239,
16
+ "tag": "0001_git-operations",
17
+ "breakpoints": true
18
+ },
19
+ {
20
+ "idx": 2,
21
+ "version": "6",
22
+ "when": 1790271413249,
23
+ "tag": "0002_merge-detection",
24
+ "breakpoints": true
25
+ },
26
+ {
27
+ "idx": 3,
28
+ "version": "6",
29
+ "when": 1790277009822,
30
+ "tag": "0003_sync-replica",
31
+ "breakpoints": true
32
+ },
33
+ {
34
+ "idx": 4,
35
+ "version": "6",
36
+ "when": 1790286496661,
37
+ "tag": "0004_chat-replica",
38
+ "breakpoints": true
39
+ },
40
+ {
41
+ "idx": 5,
42
+ "version": "6",
43
+ "when": 1790288440464,
44
+ "tag": "0005_pr-events",
45
+ "breakpoints": true
46
+ },
47
+ {
48
+ "idx": 6,
49
+ "version": "6",
50
+ "when": 1790294003288,
51
+ "tag": "0006_archive",
52
+ "breakpoints": true
53
+ }
54
+ ]
55
+ }
@@ -0,0 +1,170 @@
1
+ import { sql } from 'drizzle-orm';
2
+ import { check, index, integer, primaryKey, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core';
3
+ import { ACTIVITY_TYPES } from '../domain/activity.js';
4
+ import { WORKSPACE_ROLES } from '../domain/entities.js';
5
+ import { TASK_PRIORITIES, TASK_STATUSES, TASK_TYPES } from '../domain/task.js';
6
+ const timestamps = {
7
+ createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
8
+ updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
9
+ };
10
+ const oneOf = (column, values) => sql.raw(`${column} IN (${values.map((value) => `'${value}'`).join(', ')})`);
11
+ export const users = sqliteTable('users', {
12
+ id: text('id').primaryKey(),
13
+ username: text('username').notNull().unique(),
14
+ displayName: text('display_name').notNull(),
15
+ email: text('email'),
16
+ ...timestamps,
17
+ });
18
+ export const workspaces = sqliteTable('workspaces', {
19
+ id: text('id').primaryKey(),
20
+ name: text('name').notNull(),
21
+ slug: text('slug').notNull().unique(),
22
+ description: text('description'),
23
+ /** Highest task number ever deleted, so numbers are never reused (old commits keep pointing at the right task). */
24
+ lastDeletedNumber: integer('last_deleted_number').notNull().default(0),
25
+ ...timestamps,
26
+ });
27
+ export const workspaceMembers = sqliteTable('workspace_members', {
28
+ workspaceId: text('workspace_id')
29
+ .notNull()
30
+ .references(() => workspaces.id, { onDelete: 'cascade' }),
31
+ userId: text('user_id')
32
+ .notNull()
33
+ .references(() => users.id, { onDelete: 'cascade' }),
34
+ role: text('role', { enum: WORKSPACE_ROLES }).notNull(),
35
+ }, (t) => [primaryKey({ columns: [t.workspaceId, t.userId] }), check('workspace_members_role', oneOf('role', WORKSPACE_ROLES))]);
36
+ export const projects = sqliteTable('projects', {
37
+ id: text('id').primaryKey(),
38
+ workspaceId: text('workspace_id')
39
+ .notNull()
40
+ .references(() => workspaces.id, { onDelete: 'cascade' }),
41
+ name: text('name').notNull(),
42
+ key: text('key').notNull(),
43
+ description: text('description'),
44
+ repositoryUrl: text('repository_url'),
45
+ repositoryPath: text('repository_path'),
46
+ ...timestamps,
47
+ }, (t) => [uniqueIndex('projects_workspace_key').on(t.workspaceId, t.key)]);
48
+ export const tasks = sqliteTable('tasks', {
49
+ id: text('id').primaryKey(),
50
+ number: integer('number').notNull(),
51
+ workspaceId: text('workspace_id')
52
+ .notNull()
53
+ .references(() => workspaces.id, { onDelete: 'cascade' }),
54
+ projectId: text('project_id').references(() => projects.id, { onDelete: 'set null' }),
55
+ title: text('title').notNull(),
56
+ description: text('description'),
57
+ type: text('type', { enum: TASK_TYPES }).notNull(),
58
+ priority: text('priority', { enum: TASK_PRIORITIES }).notNull(),
59
+ status: text('status', { enum: TASK_STATUSES }).notNull(),
60
+ assigneeId: text('assignee_id').references(() => users.id, { onDelete: 'set null' }),
61
+ creatorId: text('creator_id')
62
+ .notNull()
63
+ .references(() => users.id),
64
+ requester: text('requester'),
65
+ branch: text('branch'),
66
+ baseBranch: text('base_branch'),
67
+ branchStart: text('branch_start'),
68
+ ...timestamps,
69
+ startedAt: integer('started_at', { mode: 'timestamp_ms' }),
70
+ completedAt: integer('completed_at', { mode: 'timestamp_ms' }),
71
+ archivedAt: integer('archived_at', { mode: 'timestamp_ms' }),
72
+ }, (t) => [
73
+ uniqueIndex('tasks_workspace_number').on(t.workspaceId, t.number),
74
+ index('tasks_workspace_status').on(t.workspaceId, t.status),
75
+ index('tasks_assignee').on(t.assigneeId),
76
+ index('tasks_project').on(t.projectId),
77
+ check('tasks_type', oneOf('type', TASK_TYPES)),
78
+ check('tasks_priority', oneOf('priority', TASK_PRIORITIES)),
79
+ check('tasks_status', oneOf('status', TASK_STATUSES)),
80
+ ]);
81
+ export const taskComments = sqliteTable('task_comments', {
82
+ id: text('id').primaryKey(),
83
+ taskId: text('task_id')
84
+ .notNull()
85
+ .references(() => tasks.id, { onDelete: 'cascade' }),
86
+ userId: text('user_id')
87
+ .notNull()
88
+ .references(() => users.id),
89
+ body: text('body').notNull(),
90
+ ...timestamps,
91
+ }, (t) => [index('task_comments_task').on(t.taskId)]);
92
+ export const taskActivity = sqliteTable('task_activity', {
93
+ id: text('id').primaryKey(),
94
+ taskId: text('task_id')
95
+ .notNull()
96
+ .references(() => tasks.id, { onDelete: 'cascade' }),
97
+ userId: text('user_id').references(() => users.id, { onDelete: 'set null' }),
98
+ type: text('type', { enum: ACTIVITY_TYPES }).notNull(),
99
+ metadata: text('metadata', { mode: 'json' }).$type().notNull(),
100
+ createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
101
+ }, (t) => [index('task_activity_task').on(t.taskId), check('task_activity_type', oneOf('type', ACTIVITY_TYPES))]);
102
+ // ── Remote mode replica (v0.4 offline sync). Unused in local mode. ─────────
103
+ /** Operations waiting to be sent to the SOJA server, in the order they were made. */
104
+ export const syncOutbox = sqliteTable('sync_outbox', {
105
+ seq: integer('seq').primaryKey({ autoIncrement: true }),
106
+ opId: text('op_id').notNull().unique(),
107
+ workspaceId: text('workspace_id').notNull(),
108
+ type: text('type').notNull(),
109
+ /** The task a task operation changes; null for chat operations. */
110
+ taskId: text('task_id'),
111
+ payload: text('payload', { mode: 'json' }).$type().notNull(),
112
+ base: text('base', { mode: 'json' }).$type(),
113
+ occurredAt: integer('occurred_at', { mode: 'timestamp_ms' }).notNull(),
114
+ attempts: integer('attempts').notNull().default(0),
115
+ lastError: text('last_error'),
116
+ }, (t) => [index('sync_outbox_workspace').on(t.workspaceId, t.seq)]);
117
+ /** Activity rows written optimistically for an operation; replaced by the server's once acknowledged. */
118
+ export const syncPendingActivity = sqliteTable('sync_pending_activity', {
119
+ activityId: text('activity_id').primaryKey(),
120
+ opId: text('op_id').notNull(),
121
+ });
122
+ /** Pull cursor per workspace. */
123
+ export const syncState = sqliteTable('sync_state', {
124
+ workspaceId: text('workspace_id').primaryKey(),
125
+ cursor: integer('cursor').notNull().default(0),
126
+ lastSyncAt: integer('last_sync_at', { mode: 'timestamp_ms' }),
127
+ lastError: text('last_error'),
128
+ });
129
+ /** Conflicts and rejections the user should see. */
130
+ export const syncNotices = sqliteTable('sync_notices', {
131
+ id: text('id').primaryKey(),
132
+ workspaceId: text('workspace_id').notNull(),
133
+ taskId: text('task_id'),
134
+ taskRef: text('task_ref').notNull(),
135
+ kind: text('kind', { enum: ['conflict', 'rejected'] }).notNull(),
136
+ message: text('message').notNull(),
137
+ field: text('field'),
138
+ /** For conflicts: the value your change replaced, to restore it. */
139
+ overwritten: text('overwritten', { mode: 'json' }).$type(),
140
+ createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
141
+ dismissedAt: integer('dismissed_at', { mode: 'timestamp_ms' }),
142
+ }, (t) => [index('sync_notices_workspace').on(t.workspaceId)]);
143
+ // ── Chat replica (v0.5, remote mode only) ───────────────────────────────────
144
+ export const chatChannels = sqliteTable('chat_channels', {
145
+ id: text('id').primaryKey(),
146
+ workspaceId: text('workspace_id').notNull(),
147
+ name: text('name').notNull(),
148
+ topic: text('topic'),
149
+ createdBy: text('created_by'),
150
+ ...timestamps,
151
+ archivedAt: integer('archived_at', { mode: 'timestamp_ms' }),
152
+ }, (t) => [index('chat_channels_workspace').on(t.workspaceId, t.name)]);
153
+ export const chatMessages = sqliteTable('chat_messages', {
154
+ id: text('id').primaryKey(),
155
+ /** Server order; null while the message waits in the outbox. */
156
+ seq: integer('seq'),
157
+ workspaceId: text('workspace_id').notNull(),
158
+ channelId: text('channel_id').notNull(),
159
+ authorId: text('author_id'),
160
+ body: text('body').notNull(),
161
+ replyToId: text('reply_to_id'),
162
+ createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
163
+ editedAt: integer('edited_at', { mode: 'timestamp_ms' }),
164
+ deletedAt: integer('deleted_at', { mode: 'timestamp_ms' }),
165
+ }, (t) => [index('chat_messages_channel').on(t.channelId, t.seq), index('chat_messages_workspace').on(t.workspaceId)]);
166
+ /** How far you have read each channel (yours only). */
167
+ export const chatReads = sqliteTable('chat_reads', {
168
+ channelId: text('channel_id').primaryKey(),
169
+ lastReadSeq: integer('last_read_seq').notNull().default(0),
170
+ });
@@ -0,0 +1,84 @@
1
+ import { ConflictError } from '../domain/errors.js';
2
+ const WORKSPACE = 'Bravos Development';
3
+ const DEVELOPERS = [
4
+ { username: 'michael', displayName: 'Michael' },
5
+ { username: 'angel', displayName: 'Angel' },
6
+ { username: 'freddy', displayName: 'Freddy' },
7
+ ];
8
+ const PROJECTS = [
9
+ { name: 'EnrollBridge', key: 'ENROLL', description: 'Enrollment and payments platform' },
10
+ { name: 'SPRING', key: 'SPRING', description: 'Player development and pitching analytics' },
11
+ { name: 'Taskfeeds', key: 'TASK', description: 'Internal content and video feeds' },
12
+ { name: 'Banana Gym', key: 'GYM', description: 'Gym access control and memberships' },
13
+ { name: 'Mediacore', key: 'MEDIA', description: 'Media storage and processing' },
14
+ ];
15
+ const TASKS = [
16
+ { title: 'Receipt upload not rendering after payment', project: 'EnrollBridge', type: 'bug', priority: 'urgent', status: 'in_progress', assignee: 'me', requester: 'Admissions', description: 'Parents upload the receipt, the upload succeeds, but the enrollment page keeps showing "pending receipt".', comments: ['Reproduced on staging with a PDF over 5 MB.', 'The CDN URL is signed with the wrong bucket.'] },
17
+ { title: 'Fix Stripe webhook duplicate events', project: 'EnrollBridge', type: 'bug', priority: 'high', status: 'review', assignee: 'me', requester: 'Finance', description: 'Stripe retries are creating duplicate payment records. We need idempotency on event IDs.' },
18
+ { title: 'Add pitch velocity to player profile', project: 'SPRING', type: 'feature', priority: 'high', status: 'todo', assignee: 'me', requester: 'Baseball Operations' },
19
+ { title: 'Improve video loading on slow connections', project: 'Taskfeeds', type: 'improvement', priority: 'low', status: 'todo', assignee: 'me', requester: 'Marketing' },
20
+ { title: 'Turnstile loses connection every night', project: 'Banana Gym', type: 'bug', priority: 'high', status: 'blocked', assignee: 'me', requester: 'Operations', comments: ['Waiting on the vendor to send the new firmware.'] },
21
+ { title: 'Change payment flow to support installments', project: 'EnrollBridge', type: 'feature', priority: 'medium', status: 'backlog', assignee: null, requester: 'Finance' },
22
+ { title: 'Rotate S3 credentials', project: 'Mediacore', type: 'infra', priority: 'medium', status: 'todo', assignee: 'freddy' },
23
+ { title: 'Migrate thumbnails to WebP', project: 'Mediacore', type: 'improvement', priority: 'low', status: 'in_progress', assignee: 'freddy' },
24
+ { title: 'Spin rate chart shows wrong units', project: 'SPRING', type: 'bug', priority: 'medium', status: 'review', assignee: 'angel', requester: 'Baseball Operations' },
25
+ { title: 'Research pose estimation libraries', project: 'SPRING', type: 'research', priority: 'none', status: 'backlog', assignee: 'angel' },
26
+ { title: 'Marketing banner copy fix on feed header', project: 'Taskfeeds', type: 'chore', priority: 'low', status: 'done', assignee: 'angel', requester: 'Marketing' },
27
+ { title: 'Refactor membership renewal job', project: 'Banana Gym', type: 'refactor', priority: 'medium', status: 'todo', assignee: 'angel' },
28
+ { title: 'Weekly database backup verification', project: 'Mediacore', type: 'maintenance', priority: 'medium', status: 'done', assignee: 'me' },
29
+ { title: 'Admissions export times out', project: 'EnrollBridge', type: 'bug', priority: 'high', status: 'todo', assignee: 'freddy', requester: 'Admissions' },
30
+ { title: 'Upgrade Node to 24 on all services', project: 'Mediacore', type: 'infra', priority: 'low', status: 'cancelled', assignee: null, requester: 'Management' },
31
+ ];
32
+ /**
33
+ * Demo data for trying the UI. Goes through the services, so the rows,
34
+ * numbering and activity timelines are exactly what real usage produces.
35
+ */
36
+ export async function seedDemoData(services) {
37
+ let session = await services.session.current();
38
+ if (!session) {
39
+ session = await services.session.setup({ displayName: 'Michael', username: 'michael', workspaceName: WORKSPACE });
40
+ }
41
+ else {
42
+ const existing = (await services.workspaces.list(session.user)).find((workspace) => workspace.name === WORKSPACE);
43
+ const workspace = existing ?? (await services.workspaces.create(session.user, { name: WORKSPACE }));
44
+ session = await services.workspaces.switchTo(session.user, workspace.id);
45
+ }
46
+ if ((await services.projects.list(session)).length > 0) {
47
+ throw new ConflictError(`${WORKSPACE} already has projects, so demo data was not added.`, {
48
+ hint: 'Run `npm run db:reset` first for a clean demo.',
49
+ });
50
+ }
51
+ const members = new Map((await services.workspaces.members(session)).map((member) => [member.username, member.id]));
52
+ for (const developer of DEVELOPERS) {
53
+ if (!members.has(developer.username)) {
54
+ const user = await services.workspaces.addMember(session, developer);
55
+ members.set(user.username, user.id);
56
+ }
57
+ }
58
+ const projectIds = new Map();
59
+ for (const project of PROJECTS) {
60
+ projectIds.set(project.name, (await services.projects.create(session, project)).id);
61
+ }
62
+ for (const demo of TASKS) {
63
+ const assigneeId = demo.assignee === 'me' ? session.user.id : demo.assignee ? members.get(demo.assignee) : null;
64
+ // Created as Todo and then moved, so the timeline shows real transitions.
65
+ const task = await services.tasks.create(session, {
66
+ title: demo.title,
67
+ description: demo.description ?? null,
68
+ projectId: projectIds.get(demo.project) ?? null,
69
+ type: demo.type,
70
+ priority: demo.priority,
71
+ status: demo.status === 'backlog' ? 'backlog' : 'todo',
72
+ assigneeId: assigneeId ?? null,
73
+ requester: demo.requester ?? null,
74
+ });
75
+ if (['review', 'blocked', 'done'].includes(demo.status)) {
76
+ await services.tasks.update(session, task, { status: 'in_progress' });
77
+ }
78
+ if (demo.status !== task.status)
79
+ await services.tasks.update(session, task, { status: demo.status });
80
+ for (const comment of demo.comments ?? [])
81
+ await services.tasks.comment(session, task, comment);
82
+ }
83
+ return { session, projects: PROJECTS.length, tasks: TASKS.length };
84
+ }
@@ -0,0 +1,22 @@
1
+ export const ACTIVITY_TYPES = [
2
+ 'task_created',
3
+ 'task_updated',
4
+ 'status_changed',
5
+ 'assigned',
6
+ 'unassigned',
7
+ 'priority_changed',
8
+ 'project_changed',
9
+ 'comment_added',
10
+ 'task_completed',
11
+ 'task_reopened',
12
+ 'git_committed',
13
+ 'git_merged',
14
+ 'git_branch_deleted',
15
+ 'git_pushed',
16
+ 'pr_opened',
17
+ 'git_merge_detected',
18
+ 'pr_merged',
19
+ 'pr_checks_failed',
20
+ 'task_archived',
21
+ 'task_unarchived',
22
+ ];
@@ -0,0 +1,21 @@
1
+ import { TASK_REF_PREFIX } from './task.js';
2
+ export const MAX_MESSAGE_LENGTH = 4000;
3
+ export const CHANNEL_NAME = /^[a-z0-9][a-z0-9-]{0,39}$/;
4
+ /** `#General ` → `general`. */
5
+ export function normalizeChannelName(name) {
6
+ return name.trim().toLowerCase().replace(/^#/, '');
7
+ }
8
+ // Same rules as the server, which resolves them to real members and tasks.
9
+ const MENTION = /(^|[^\w@])@([a-z0-9][a-z0-9._-]{0,38})/gi;
10
+ const TASK_REF = new RegExp(`\\b${TASK_REF_PREFIX}-(\\d{1,9})\\b`, 'gi');
11
+ /** Usernames written as `@name`, lowercased and unique. */
12
+ export function mentionsIn(body) {
13
+ return [...new Set([...body.matchAll(MENTION)].map((match) => (match[2] ?? '').toLowerCase()))];
14
+ }
15
+ /** Task numbers written as `SOJA-12`. */
16
+ export function taskRefsIn(body) {
17
+ return [...new Set([...body.matchAll(TASK_REF)].map((match) => Number(match[1])))];
18
+ }
19
+ export function mentions(body, username) {
20
+ return mentionsIn(body).includes(username.toLowerCase());
21
+ }
@@ -0,0 +1 @@
1
+ export const WORKSPACE_ROLES = ['owner', 'member'];
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Errors meant to be shown to people. `message` is a short sentence,
3
+ * `hint` an optional next step. Anything that is not a SojaError is a bug
4
+ * and gets a generic message (details only with SOJA_DEBUG=1).
5
+ */
6
+ export class SojaError extends Error {
7
+ hint;
8
+ constructor(message, options = {}) {
9
+ super(message, { cause: options.cause });
10
+ this.name = new.target.name;
11
+ this.hint = options.hint;
12
+ }
13
+ }
14
+ export class NotFoundError extends SojaError {
15
+ }
16
+ export class ValidationError extends SojaError {
17
+ }
18
+ export class ConflictError extends SojaError {
19
+ }
20
+ export class StorageError extends SojaError {
21
+ }
22
+ export class ConfigError extends SojaError {
23
+ }
@@ -0,0 +1,33 @@
1
+ export function slugify(value) {
2
+ return value
3
+ .normalize('NFKD')
4
+ .replace(/[̀-ͯ]/g, '')
5
+ .toLowerCase()
6
+ .replace(/[^a-z0-9]+/g, '-')
7
+ .replace(/^-+|-+$/g, '');
8
+ }
9
+ /**
10
+ * Short uppercase key for a project name: the first word when it is short
11
+ * (`SPRING`, `ENROLL` from EnrollBridge), otherwise its first four letters
12
+ * (`TASK` from Taskfeeds).
13
+ */
14
+ export function deriveProjectKey(name) {
15
+ const words = name
16
+ .normalize('NFKD')
17
+ .replace(/[̀-ͯ]/g, '')
18
+ .replace(/([a-z])([A-Z])/g, '$1 $2')
19
+ .split(/[^A-Za-z0-9]+/)
20
+ .filter(Boolean);
21
+ const first = (words[0] ?? 'PROJ').toUpperCase();
22
+ return first.length <= 6 ? first : first.slice(0, 4);
23
+ }
24
+ /** Appends 2, 3, … (after `separator`) until `isTaken` is false. */
25
+ export function makeUnique(base, isTaken, separator = '') {
26
+ if (!isTaken(base))
27
+ return base;
28
+ for (let n = 2;; n += 1) {
29
+ const candidate = `${base}${separator}${n}`;
30
+ if (!isTaken(candidate))
31
+ return candidate;
32
+ }
33
+ }
@@ -0,0 +1,140 @@
1
+ export const TASK_TYPES = [
2
+ 'bug',
3
+ 'feature',
4
+ 'improvement',
5
+ 'maintenance',
6
+ 'infra',
7
+ 'refactor',
8
+ 'research',
9
+ 'chore',
10
+ ];
11
+ export const TASK_PRIORITIES = ['none', 'low', 'medium', 'high', 'urgent'];
12
+ export const TASK_STATUSES = [
13
+ 'backlog',
14
+ 'todo',
15
+ 'in_progress',
16
+ 'review',
17
+ 'blocked',
18
+ 'done',
19
+ 'cancelled',
20
+ ];
21
+ export const TYPE_LABELS = {
22
+ bug: 'Bug',
23
+ feature: 'Feature',
24
+ improvement: 'Improvement',
25
+ maintenance: 'Maintenance',
26
+ infra: 'Infra',
27
+ refactor: 'Refactor',
28
+ research: 'Research',
29
+ chore: 'Chore',
30
+ };
31
+ export const PRIORITY_LABELS = {
32
+ none: 'NONE',
33
+ low: 'LOW',
34
+ medium: 'MED',
35
+ high: 'HIGH',
36
+ urgent: 'URGENT',
37
+ };
38
+ export const STATUS_LABELS = {
39
+ backlog: 'Backlog',
40
+ todo: 'Todo',
41
+ in_progress: 'In Progress',
42
+ review: 'Review',
43
+ blocked: 'Blocked',
44
+ done: 'Done',
45
+ cancelled: 'Cancelled',
46
+ };
47
+ /** Human identifier prefix. Numbers are sequential per workspace. */
48
+ export const TASK_REF_PREFIX = 'SOJA';
49
+ /**
50
+ * `SOJA-12`. Tasks created offline in remote mode get provisional negative
51
+ * numbers until the server assigns the real one: -1 shows as `SOJA-?1`.
52
+ */
53
+ export function formatTaskRef(number) {
54
+ return number < 0 ? `${TASK_REF_PREFIX}-?${-number}` : `${TASK_REF_PREFIX}-${number}`;
55
+ }
56
+ export function isProvisional(number) {
57
+ return number < 0;
58
+ }
59
+ /**
60
+ * Extended regex (for `git log --grep -E -i`) matching a mention of the task
61
+ * in a commit message: `SOJA-12` but not `SOJA-123`.
62
+ */
63
+ export function taskMentionPattern(number) {
64
+ return `${TASK_REF_PREFIX}-${number}([^0-9]|$)`;
65
+ }
66
+ /** Accepts `SOJA-12`, `soja-12`, `#12` or `12`, and provisional `SOJA-?1` / `?1`. */
67
+ export function parseTaskRef(input) {
68
+ const match = /^(?:soja-|#)?(\?)?(\d{1,9})$/i.exec(input.trim());
69
+ if (!match?.[2])
70
+ return null;
71
+ const value = Number(match[2]);
72
+ if (value <= 0)
73
+ return null;
74
+ return match[1] ? -value : value;
75
+ }
76
+ export function isClosed(status) {
77
+ return status === 'done' || status === 'cancelled';
78
+ }
79
+ /** Work someone is expected to touch soon: excludes backlog and closed tasks. */
80
+ export function isActive(status) {
81
+ return !isClosed(status) && status !== 'backlog';
82
+ }
83
+ const STATUS_ORDER = {
84
+ in_progress: 0,
85
+ review: 1,
86
+ blocked: 2,
87
+ todo: 3,
88
+ backlog: 4,
89
+ done: 5,
90
+ cancelled: 6,
91
+ };
92
+ const PRIORITY_ORDER = {
93
+ urgent: 0,
94
+ high: 1,
95
+ medium: 2,
96
+ low: 3,
97
+ none: 4,
98
+ };
99
+ /** "What should I do now" ordering: work in flight first, then by priority, newest first. */
100
+ export function compareByUrgency(a, b) {
101
+ return (STATUS_ORDER[a.status] - STATUS_ORDER[b.status] ||
102
+ PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority] ||
103
+ b.number - a.number);
104
+ }
105
+ export function emptyStatusCounts() {
106
+ return { backlog: 0, todo: 0, in_progress: 0, review: 0, blocked: 0, done: 0, cancelled: 0 };
107
+ }
108
+ export function countStatuses(tasks) {
109
+ const counts = emptyStatusCounts();
110
+ for (const task of tasks)
111
+ counts[task.status] += 1;
112
+ return counts;
113
+ }
114
+ export function activeCount(counts) {
115
+ return counts.todo + counts.in_progress + counts.review + counts.blocked;
116
+ }
117
+ const BRANCH_PREFIX = {
118
+ bug: 'fix',
119
+ feature: 'feat',
120
+ improvement: 'feat',
121
+ maintenance: 'chore',
122
+ infra: 'infra',
123
+ refactor: 'refactor',
124
+ research: 'research',
125
+ chore: 'chore',
126
+ };
127
+ /** Branch name a future `soja start` would create, e.g. `fix/SOJA-342-stripe-webhook`. */
128
+ export function suggestBranchName(task) {
129
+ const slug = task.title
130
+ .normalize('NFKD')
131
+ .replace(/[̀-ͯ]/g, '')
132
+ .toLowerCase()
133
+ .replace(/[^a-z0-9]+/g, '-')
134
+ .replace(/^-+|-+$/g, '')
135
+ .split('-')
136
+ .slice(0, 5)
137
+ .join('-');
138
+ const base = `${BRANCH_PREFIX[task.type]}/${formatTaskRef(task.number)}`;
139
+ return slug ? `${base}-${slug}` : base;
140
+ }
@@ -0,0 +1,23 @@
1
+ import { isClosed } from './task.js';
2
+ /**
3
+ * Rules for moving a task between statuses. Every transition is allowed
4
+ * (teams move fast and fix mistakes), but timestamps and the recorded
5
+ * event depend on where the task is coming from and going to.
6
+ */
7
+ export function planStatusChange(task, to, now) {
8
+ const from = task.status;
9
+ if (from === to)
10
+ return null;
11
+ const startedAt = task.startedAt ?? (to === 'in_progress' ? now : null);
12
+ const patch = { status: to, startedAt, completedAt: task.completedAt };
13
+ if (to === 'done') {
14
+ return { patch: { ...patch, completedAt: now }, event: { type: 'task_completed', metadata: { from } } };
15
+ }
16
+ if (isClosed(from) && !isClosed(to)) {
17
+ return { patch: { ...patch, completedAt: null }, event: { type: 'task_reopened', metadata: { from, to } } };
18
+ }
19
+ return {
20
+ patch: { ...patch, completedAt: to === 'cancelled' ? null : patch.completedAt },
21
+ event: { type: 'status_changed', metadata: { from, to } },
22
+ };
23
+ }