@assistkick/create 1.0.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 (178) hide show
  1. package/dist/bin/create.d.ts +2 -0
  2. package/dist/bin/create.js +25 -0
  3. package/dist/bin/create.js.map +1 -0
  4. package/dist/src/scaffolder.d.ts +22 -0
  5. package/dist/src/scaffolder.js +120 -0
  6. package/dist/src/scaffolder.js.map +1 -0
  7. package/package.json +24 -0
  8. package/templates/product-system/.env.example +8 -0
  9. package/templates/product-system/CLAUDE.md +45 -0
  10. package/templates/product-system/package.json +32 -0
  11. package/templates/product-system/packages/backend/package.json +37 -0
  12. package/templates/product-system/packages/backend/src/middleware/auth_middleware.test.ts +86 -0
  13. package/templates/product-system/packages/backend/src/middleware/auth_middleware.ts +35 -0
  14. package/templates/product-system/packages/backend/src/routes/auth.ts +463 -0
  15. package/templates/product-system/packages/backend/src/routes/coherence.ts +187 -0
  16. package/templates/product-system/packages/backend/src/routes/graph.ts +67 -0
  17. package/templates/product-system/packages/backend/src/routes/kanban.ts +201 -0
  18. package/templates/product-system/packages/backend/src/routes/pipeline.ts +41 -0
  19. package/templates/product-system/packages/backend/src/routes/projects.ts +122 -0
  20. package/templates/product-system/packages/backend/src/routes/users.ts +97 -0
  21. package/templates/product-system/packages/backend/src/server.ts +159 -0
  22. package/templates/product-system/packages/backend/src/services/auth_service.test.ts +115 -0
  23. package/templates/product-system/packages/backend/src/services/auth_service.ts +82 -0
  24. package/templates/product-system/packages/backend/src/services/coherence-review.ts +339 -0
  25. package/templates/product-system/packages/backend/src/services/email_service.ts +75 -0
  26. package/templates/product-system/packages/backend/src/services/init.ts +80 -0
  27. package/templates/product-system/packages/backend/src/services/invitation_service.test.ts +235 -0
  28. package/templates/product-system/packages/backend/src/services/invitation_service.ts +193 -0
  29. package/templates/product-system/packages/backend/src/services/password_reset_service.test.ts +151 -0
  30. package/templates/product-system/packages/backend/src/services/password_reset_service.ts +135 -0
  31. package/templates/product-system/packages/backend/src/services/project_service.test.ts +215 -0
  32. package/templates/product-system/packages/backend/src/services/project_service.ts +171 -0
  33. package/templates/product-system/packages/backend/src/services/pty_session_manager.test.ts +88 -0
  34. package/templates/product-system/packages/backend/src/services/pty_session_manager.ts +279 -0
  35. package/templates/product-system/packages/backend/src/services/terminal_ws_handler.ts +133 -0
  36. package/templates/product-system/packages/backend/src/services/user_management_service.test.ts +158 -0
  37. package/templates/product-system/packages/backend/src/services/user_management_service.ts +128 -0
  38. package/templates/product-system/packages/backend/tsconfig.json +22 -0
  39. package/templates/product-system/packages/frontend/index.html +13 -0
  40. package/templates/product-system/packages/frontend/package-lock.json +2666 -0
  41. package/templates/product-system/packages/frontend/package.json +30 -0
  42. package/templates/product-system/packages/frontend/public/favicon.svg +16 -0
  43. package/templates/product-system/packages/frontend/src/App.tsx +29 -0
  44. package/templates/product-system/packages/frontend/src/api/client.ts +386 -0
  45. package/templates/product-system/packages/frontend/src/api/client_projects.test.ts +104 -0
  46. package/templates/product-system/packages/frontend/src/api/client_refresh.test.ts +145 -0
  47. package/templates/product-system/packages/frontend/src/components/CoherenceView.tsx +414 -0
  48. package/templates/product-system/packages/frontend/src/components/GraphLegend.tsx +124 -0
  49. package/templates/product-system/packages/frontend/src/components/GraphSettings.tsx +112 -0
  50. package/templates/product-system/packages/frontend/src/components/GraphView.tsx +370 -0
  51. package/templates/product-system/packages/frontend/src/components/InviteUserDialog.tsx +85 -0
  52. package/templates/product-system/packages/frontend/src/components/KanbanView.tsx +470 -0
  53. package/templates/product-system/packages/frontend/src/components/LoginPage.tsx +116 -0
  54. package/templates/product-system/packages/frontend/src/components/ProjectSelector.tsx +187 -0
  55. package/templates/product-system/packages/frontend/src/components/QaIssueSheet.tsx +192 -0
  56. package/templates/product-system/packages/frontend/src/components/SidePanel.tsx +231 -0
  57. package/templates/product-system/packages/frontend/src/components/TerminalView.tsx +200 -0
  58. package/templates/product-system/packages/frontend/src/components/Toolbar.tsx +84 -0
  59. package/templates/product-system/packages/frontend/src/components/UsersView.tsx +249 -0
  60. package/templates/product-system/packages/frontend/src/constants/graph.ts +191 -0
  61. package/templates/product-system/packages/frontend/src/hooks/useAuth.tsx +54 -0
  62. package/templates/product-system/packages/frontend/src/hooks/useGraph.ts +27 -0
  63. package/templates/product-system/packages/frontend/src/hooks/useKanban.ts +21 -0
  64. package/templates/product-system/packages/frontend/src/hooks/useProjects.ts +86 -0
  65. package/templates/product-system/packages/frontend/src/hooks/useTheme.ts +26 -0
  66. package/templates/product-system/packages/frontend/src/hooks/useToast.tsx +62 -0
  67. package/templates/product-system/packages/frontend/src/hooks/use_projects_logic.test.ts +61 -0
  68. package/templates/product-system/packages/frontend/src/main.tsx +12 -0
  69. package/templates/product-system/packages/frontend/src/pages/accept_invitation_page.tsx +167 -0
  70. package/templates/product-system/packages/frontend/src/pages/forgot_password_page.tsx +100 -0
  71. package/templates/product-system/packages/frontend/src/pages/register_page.tsx +137 -0
  72. package/templates/product-system/packages/frontend/src/pages/reset_password_page.tsx +146 -0
  73. package/templates/product-system/packages/frontend/src/routes/ProtectedRoute.tsx +12 -0
  74. package/templates/product-system/packages/frontend/src/routes/accept_invitation.tsx +14 -0
  75. package/templates/product-system/packages/frontend/src/routes/dashboard.tsx +221 -0
  76. package/templates/product-system/packages/frontend/src/routes/forgot_password.tsx +13 -0
  77. package/templates/product-system/packages/frontend/src/routes/login.tsx +14 -0
  78. package/templates/product-system/packages/frontend/src/routes/register.tsx +14 -0
  79. package/templates/product-system/packages/frontend/src/routes/reset_password.tsx +13 -0
  80. package/templates/product-system/packages/frontend/src/styles/index.css +3358 -0
  81. package/templates/product-system/packages/frontend/src/utils/auth_validation.test.ts +51 -0
  82. package/templates/product-system/packages/frontend/src/utils/auth_validation.ts +19 -0
  83. package/templates/product-system/packages/frontend/src/utils/login_validation.test.ts +61 -0
  84. package/templates/product-system/packages/frontend/src/utils/login_validation.ts +24 -0
  85. package/templates/product-system/packages/frontend/src/utils/logout.test.ts +63 -0
  86. package/templates/product-system/packages/frontend/src/utils/node_sizing.test.ts +62 -0
  87. package/templates/product-system/packages/frontend/src/utils/node_sizing.ts +24 -0
  88. package/templates/product-system/packages/frontend/src/utils/task_status.test.ts +53 -0
  89. package/templates/product-system/packages/frontend/src/utils/task_status.ts +14 -0
  90. package/templates/product-system/packages/frontend/tsconfig.json +21 -0
  91. package/templates/product-system/packages/frontend/vite.config.ts +20 -0
  92. package/templates/product-system/packages/shared/.env.example +3 -0
  93. package/templates/product-system/packages/shared/README.md +1 -0
  94. package/templates/product-system/packages/shared/db/migrate.ts +32 -0
  95. package/templates/product-system/packages/shared/db/migrations/0000_dashing_gorgon.sql +128 -0
  96. package/templates/product-system/packages/shared/db/migrations/meta/0000_snapshot.json +819 -0
  97. package/templates/product-system/packages/shared/db/migrations/meta/_journal.json +13 -0
  98. package/templates/product-system/packages/shared/db/schema.ts +137 -0
  99. package/templates/product-system/packages/shared/drizzle.config.js +14 -0
  100. package/templates/product-system/packages/shared/lib/claude-service.ts +215 -0
  101. package/templates/product-system/packages/shared/lib/coherence.ts +278 -0
  102. package/templates/product-system/packages/shared/lib/completeness.ts +30 -0
  103. package/templates/product-system/packages/shared/lib/constants.ts +327 -0
  104. package/templates/product-system/packages/shared/lib/db.ts +81 -0
  105. package/templates/product-system/packages/shared/lib/git_workflow.ts +110 -0
  106. package/templates/product-system/packages/shared/lib/graph.ts +186 -0
  107. package/templates/product-system/packages/shared/lib/kanban.ts +161 -0
  108. package/templates/product-system/packages/shared/lib/markdown.ts +205 -0
  109. package/templates/product-system/packages/shared/lib/pipeline-state-store.ts +124 -0
  110. package/templates/product-system/packages/shared/lib/pipeline.ts +489 -0
  111. package/templates/product-system/packages/shared/lib/prompt_builder.ts +170 -0
  112. package/templates/product-system/packages/shared/lib/relevance_search.ts +159 -0
  113. package/templates/product-system/packages/shared/lib/session.ts +152 -0
  114. package/templates/product-system/packages/shared/lib/validator.ts +117 -0
  115. package/templates/product-system/packages/shared/lib/work_summary_parser.ts +130 -0
  116. package/templates/product-system/packages/shared/package.json +30 -0
  117. package/templates/product-system/packages/shared/scripts/assign-project.ts +52 -0
  118. package/templates/product-system/packages/shared/tools/add_edge.ts +61 -0
  119. package/templates/product-system/packages/shared/tools/add_node.ts +101 -0
  120. package/templates/product-system/packages/shared/tools/end_session.ts +87 -0
  121. package/templates/product-system/packages/shared/tools/get_gaps.ts +87 -0
  122. package/templates/product-system/packages/shared/tools/get_kanban.ts +125 -0
  123. package/templates/product-system/packages/shared/tools/get_node.ts +78 -0
  124. package/templates/product-system/packages/shared/tools/get_status.ts +98 -0
  125. package/templates/product-system/packages/shared/tools/migrate_to_turso.ts +385 -0
  126. package/templates/product-system/packages/shared/tools/move_card.ts +143 -0
  127. package/templates/product-system/packages/shared/tools/rebuild_index.ts +77 -0
  128. package/templates/product-system/packages/shared/tools/remove_edge.ts +59 -0
  129. package/templates/product-system/packages/shared/tools/remove_node.ts +96 -0
  130. package/templates/product-system/packages/shared/tools/resolve_question.ts +75 -0
  131. package/templates/product-system/packages/shared/tools/search_nodes.ts +106 -0
  132. package/templates/product-system/packages/shared/tools/start_session.ts +144 -0
  133. package/templates/product-system/packages/shared/tools/update_node.ts +133 -0
  134. package/templates/product-system/packages/shared/tsconfig.json +24 -0
  135. package/templates/product-system/pnpm-workspace.yaml +2 -0
  136. package/templates/product-system/smoke_test.ts +219 -0
  137. package/templates/product-system/tests/coherence_review.test.ts +562 -0
  138. package/templates/product-system/tests/db_sqlite_fallback.test.ts +75 -0
  139. package/templates/product-system/tests/edge_type_color_coding.test.ts +147 -0
  140. package/templates/product-system/tests/emit-tool-use-events.test.ts +85 -0
  141. package/templates/product-system/tests/feature_kind.test.ts +139 -0
  142. package/templates/product-system/tests/gap_indicators.test.ts +199 -0
  143. package/templates/product-system/tests/graceful_init.test.ts +142 -0
  144. package/templates/product-system/tests/graph_legend.test.ts +314 -0
  145. package/templates/product-system/tests/graph_settings_sheet.test.ts +804 -0
  146. package/templates/product-system/tests/hide_defined_filter.test.ts +205 -0
  147. package/templates/product-system/tests/kanban.test.ts +529 -0
  148. package/templates/product-system/tests/neighborhood_focus.test.ts +132 -0
  149. package/templates/product-system/tests/node_search.test.ts +340 -0
  150. package/templates/product-system/tests/node_sizing.test.ts +170 -0
  151. package/templates/product-system/tests/node_type_toggle_filters.test.ts +285 -0
  152. package/templates/product-system/tests/node_type_visual_encoding.test.ts +103 -0
  153. package/templates/product-system/tests/pipeline-state-store.test.ts +268 -0
  154. package/templates/product-system/tests/pipeline-unit.test.ts +593 -0
  155. package/templates/product-system/tests/pipeline.test.ts +195 -0
  156. package/templates/product-system/tests/pipeline_stats_all_cards.test.ts +193 -0
  157. package/templates/product-system/tests/play_all.test.ts +296 -0
  158. package/templates/product-system/tests/qa_issue_sheet.test.ts +464 -0
  159. package/templates/product-system/tests/relevance_search.test.ts +186 -0
  160. package/templates/product-system/tests/search_reorder.test.ts +88 -0
  161. package/templates/product-system/tests/serve_ui.test.ts +281 -0
  162. package/templates/product-system/tests/serve_ui_drizzle.test.ts +114 -0
  163. package/templates/product-system/tests/session_context_recall.test.ts +135 -0
  164. package/templates/product-system/tests/side_panel.test.ts +345 -0
  165. package/templates/product-system/tests/spec_completeness_label.test.ts +69 -0
  166. package/templates/product-system/tests/url_routing_test.ts +122 -0
  167. package/templates/product-system/tests/user_login.test.ts +150 -0
  168. package/templates/product-system/tests/user_registration.test.ts +205 -0
  169. package/templates/product-system/tests/web_terminal.test.ts +572 -0
  170. package/templates/product-system/tests/work_summary.test.ts +211 -0
  171. package/templates/product-system/tests/zoom_pan.test.ts +43 -0
  172. package/templates/product-system/tsconfig.json +24 -0
  173. package/templates/skills/product-bootstrap/SKILL.md +312 -0
  174. package/templates/skills/product-code-reviewer/SKILL.md +147 -0
  175. package/templates/skills/product-debugger/SKILL.md +206 -0
  176. package/templates/skills/product-debugger/references/agent-browser.md +1156 -0
  177. package/templates/skills/product-developer/SKILL.md +182 -0
  178. package/templates/skills/product-interview/SKILL.md +220 -0
@@ -0,0 +1,115 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { AuthService } from './auth_service.ts';
4
+
5
+ const createService = () => new AuthService({
6
+ jwtSecret: 'test-secret-at-least-32-chars-long!!',
7
+ isProduction: false,
8
+ });
9
+
10
+ describe('AuthService', () => {
11
+ describe('generateAccessToken / verifyToken', () => {
12
+ it('generates a valid access token with user claims', async () => {
13
+ const service = createService();
14
+ const token = await service.generateAccessToken('user-1', 'test@example.com', 'admin');
15
+ const payload = await service.verifyToken(token);
16
+
17
+ assert.equal(payload.sub, 'user-1');
18
+ assert.equal(payload.email, 'test@example.com');
19
+ assert.equal(payload.role, 'admin');
20
+ assert.equal(payload.type, undefined);
21
+ });
22
+ });
23
+
24
+ describe('generateRefreshToken / verifyToken', () => {
25
+ it('generates a valid refresh token with type=refresh', async () => {
26
+ const service = createService();
27
+ const token = await service.generateRefreshToken('user-1');
28
+ const payload = await service.verifyToken(token);
29
+
30
+ assert.equal(payload.sub, 'user-1');
31
+ assert.equal(payload.type, 'refresh');
32
+ assert.equal(payload.email, undefined);
33
+ });
34
+ });
35
+
36
+ describe('verifyToken', () => {
37
+ it('rejects tokens signed with a different secret', async () => {
38
+ const service1 = createService();
39
+ const service2 = new AuthService({
40
+ jwtSecret: 'different-secret-at-least-32-chars!!',
41
+ isProduction: false,
42
+ });
43
+
44
+ const token = await service1.generateAccessToken('user-1', 'test@example.com', 'user');
45
+ await assert.rejects(() => service2.verifyToken(token));
46
+ });
47
+
48
+ it('rejects malformed tokens', async () => {
49
+ const service = createService();
50
+ await assert.rejects(() => service.verifyToken('not-a-jwt'));
51
+ });
52
+ });
53
+
54
+ describe('hashPassword / verifyPassword', () => {
55
+ it('hashes and verifies passwords correctly', async () => {
56
+ const service = createService();
57
+ const hashed = await service.hashPassword('mypassword');
58
+ const valid = await service.verifyPassword('mypassword', hashed);
59
+ const invalid = await service.verifyPassword('wrongpassword', hashed);
60
+
61
+ assert.equal(valid, true);
62
+ assert.equal(invalid, false);
63
+ });
64
+ });
65
+
66
+ describe('setAuthCookies', () => {
67
+ it('sets access_token and refresh_token cookies', () => {
68
+ const service = createService();
69
+ const cookies: Record<string, any> = {};
70
+ const mockRes = {
71
+ cookie: (name: string, value: string, opts: any) => {
72
+ cookies[name] = { value, opts };
73
+ },
74
+ } as any;
75
+
76
+ service.setAuthCookies(mockRes, 'access-jwt', 'refresh-jwt');
77
+
78
+ assert.ok(cookies['access_token']);
79
+ assert.equal(cookies['access_token'].value, 'access-jwt');
80
+ assert.equal(cookies['access_token'].opts.httpOnly, true);
81
+ assert.equal(cookies['access_token'].opts.secure, false); // not production
82
+
83
+ assert.ok(cookies['refresh_token']);
84
+ assert.equal(cookies['refresh_token'].value, 'refresh-jwt');
85
+ assert.equal(cookies['refresh_token'].opts.path, '/api/auth');
86
+ });
87
+ });
88
+
89
+ describe('clearAuthCookies', () => {
90
+ it('clears both cookies', () => {
91
+ const service = createService();
92
+ const cleared: string[] = [];
93
+ const mockRes = {
94
+ clearCookie: (name: string, _opts?: any) => {
95
+ cleared.push(name);
96
+ },
97
+ } as any;
98
+
99
+ service.clearAuthCookies(mockRes);
100
+
101
+ assert.ok(cleared.includes('access_token'));
102
+ assert.ok(cleared.includes('refresh_token'));
103
+ });
104
+ });
105
+
106
+ describe('isProductionMode', () => {
107
+ it('reflects the constructor parameter', () => {
108
+ const devService = new AuthService({ jwtSecret: 'test-secret-32-chars-long!!!!!!!', isProduction: false });
109
+ const prodService = new AuthService({ jwtSecret: 'test-secret-32-chars-long!!!!!!!', isProduction: true });
110
+
111
+ assert.equal(devService.isProductionMode, false);
112
+ assert.equal(prodService.isProductionMode, true);
113
+ });
114
+ });
115
+ });
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Authentication service — handles password hashing, JWT generation,
3
+ * and cookie management for user authentication.
4
+ */
5
+
6
+ import { hash, compare } from 'bcryptjs';
7
+ import { SignJWT, jwtVerify } from 'jose';
8
+ import type { Response } from 'express';
9
+
10
+ const SALT_ROUNDS = 12;
11
+ const ACCESS_TOKEN_EXPIRY = '30m';
12
+ const REFRESH_TOKEN_EXPIRY = '7d';
13
+ const ACCESS_COOKIE_MAX_AGE = 30 * 60 * 1000; // 30 minutes
14
+ const REFRESH_COOKIE_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days
15
+
16
+ interface AuthServiceDeps {
17
+ jwtSecret: string;
18
+ isProduction: boolean;
19
+ }
20
+
21
+ export class AuthService {
22
+ private readonly secret: Uint8Array;
23
+ readonly isProductionMode: boolean;
24
+
25
+ constructor({ jwtSecret, isProduction }: AuthServiceDeps) {
26
+ this.secret = new TextEncoder().encode(jwtSecret);
27
+ this.isProductionMode = isProduction;
28
+ }
29
+
30
+ hashPassword = async (password: string): Promise<string> => {
31
+ return hash(password, SALT_ROUNDS);
32
+ };
33
+
34
+ verifyPassword = async (password: string, passwordHash: string): Promise<boolean> => {
35
+ return compare(password, passwordHash);
36
+ };
37
+
38
+ generateAccessToken = async (userId: string, email: string, role: string): Promise<string> => {
39
+ return new SignJWT({ sub: userId, email, role })
40
+ .setProtectedHeader({ alg: 'HS256' })
41
+ .setIssuedAt()
42
+ .setExpirationTime(ACCESS_TOKEN_EXPIRY)
43
+ .sign(this.secret);
44
+ };
45
+
46
+ generateRefreshToken = async (userId: string): Promise<string> => {
47
+ return new SignJWT({ sub: userId, type: 'refresh' })
48
+ .setProtectedHeader({ alg: 'HS256' })
49
+ .setIssuedAt()
50
+ .setExpirationTime(REFRESH_TOKEN_EXPIRY)
51
+ .sign(this.secret);
52
+ };
53
+
54
+ verifyToken = async (token: string): Promise<{ sub: string; email?: string; role?: string; type?: string }> => {
55
+ const { payload } = await jwtVerify(token, this.secret);
56
+ return payload as { sub: string; email?: string; role?: string; type?: string };
57
+ };
58
+
59
+ setAuthCookies = (res: Response, accessToken: string, refreshToken: string): void => {
60
+ const cookieOptions = {
61
+ httpOnly: true,
62
+ secure: this.isProductionMode,
63
+ sameSite: 'strict' as const,
64
+ };
65
+
66
+ res.cookie('access_token', accessToken, {
67
+ ...cookieOptions,
68
+ maxAge: ACCESS_COOKIE_MAX_AGE,
69
+ });
70
+
71
+ res.cookie('refresh_token', refreshToken, {
72
+ ...cookieOptions,
73
+ maxAge: REFRESH_COOKIE_MAX_AGE,
74
+ path: '/api/auth',
75
+ });
76
+ };
77
+
78
+ clearAuthCookies = (res: Response): void => {
79
+ res.clearCookie('access_token');
80
+ res.clearCookie('refresh_token', { path: '/api/auth' });
81
+ };
82
+ }
@@ -0,0 +1,339 @@
1
+ /**
2
+ * Coherence review orchestration — prompt building, AI review, proposal execution.
3
+ */
4
+
5
+ import { join } from 'node:path';
6
+ import { randomUUID } from 'node:crypto';
7
+ import { readGraph, removeEdge as removeEdgeDb, patchNode as patchNodeDb } from '@interview-system/shared/lib/graph.js';
8
+ import { loadCoherence, saveCoherence, getDismissalKey, sortProposals } from '@interview-system/shared/lib/coherence.js';
9
+ import { getDb } from '@interview-system/shared/lib/db.js';
10
+ import { nodes } from '@interview-system/shared/db/schema.js';
11
+ import { eq } from 'drizzle-orm';
12
+ import { claudeService, paths, log } from './init.js';
13
+
14
+ // --- Coherence review state ---
15
+ export let coherenceRunning = false;
16
+ export let coherenceProgress = { current: 0, total: 0, message: '' };
17
+
18
+ export const readCoherenceData = async (projectId?: string) => loadCoherence(projectId);
19
+ export const writeCoherenceData = async (data: any, projectId?: string) => saveCoherence(data, projectId);
20
+
21
+ /**
22
+ * Expand scope nodes with 1-hop neighbors to catch stale references.
23
+ */
24
+ const expandScope = (scopeNodeIds: string[], graph: any) => {
25
+ const expanded = new Set(scopeNodeIds);
26
+ for (const nodeId of scopeNodeIds) {
27
+ for (const edge of graph.edges) {
28
+ if (edge.from === nodeId) expanded.add(edge.to);
29
+ if (edge.to === nodeId) expanded.add(edge.from);
30
+ }
31
+ }
32
+ return [...expanded];
33
+ };
34
+
35
+ /**
36
+ * Build the enhanced coherence prompt with deep context.
37
+ */
38
+ export const buildCoherencePrompt = (graph: any, coherenceData: any) => {
39
+ const lastReview = coherenceData.last_review_timestamp;
40
+
41
+ const dismissedKeys = new Set(
42
+ coherenceData.proposals
43
+ .filter((p: any) => p.status === 'dismissed')
44
+ .map((p: any) => getDismissalKey(p))
45
+ );
46
+ const dismissedList = [...dismissedKeys];
47
+
48
+ const changedNodes = lastReview
49
+ ? graph.nodes.filter((n: any) => n.updated_at > lastReview || n.created_at > lastReview)
50
+ : graph.nodes;
51
+ const changedIds = changedNodes.map((n: any) => n.id);
52
+ const scopeNodeIds = lastReview ? expandScope(changedIds, graph) : changedIds;
53
+
54
+ const nodesSummary = graph.nodes.map((n: any) =>
55
+ ` ${n.id} [${n.type}] "${n.name}" status=${n.status} completeness=${n.completeness}`
56
+ ).join('\n');
57
+
58
+ const edgesSummary = graph.edges.map((e: any) =>
59
+ ` ${e.from} --${e.relation}--> ${e.to}`
60
+ ).join('\n');
61
+
62
+ const contextNodes = graph.nodes.filter((n: any) =>
63
+ n.type === 'decision' || n.type === 'tech_choice'
64
+ );
65
+ const contextSummary = contextNodes.map((n: any) =>
66
+ ` ${n.id} [${n.type}] "${n.name}" status=${n.status}`
67
+ ).join('\n');
68
+
69
+ const validRelations = [
70
+ 'contains', 'depends_on', 'governed_by', 'constrained_by',
71
+ 'implemented_with', 'reads_writes', 'exposes', 'consumes',
72
+ 'performed_by', 'escalates_to', 'relates_to'
73
+ ];
74
+
75
+ const validNodeTypes = [
76
+ 'feature', 'component', 'data_entity', 'decision', 'tech_choice',
77
+ 'non_functional_requirement', 'design_token', 'design_pattern',
78
+ 'user_role', 'flow', 'assumption', 'open_question'
79
+ ];
80
+
81
+ let prompt = `You are a graph coherence reviewer for a product specification knowledge graph. `;
82
+ prompt += `Analyze the graph and propose fixes for inconsistencies, stale references, and missing connections.\n\n`;
83
+ prompt += `## All Nodes\n${nodesSummary}\n\n`;
84
+ prompt += `## All Edges\n${edgesSummary}\n\n`;
85
+
86
+ if (contextNodes.length > 0) {
87
+ prompt += `## Architectural Context (decisions and tech choices — cross-reference these)\n${contextSummary}\n\n`;
88
+ }
89
+
90
+ prompt += `## Scope (nodes to focus on)\n`;
91
+ prompt += scopeNodeIds.length > 0
92
+ ? `Focus on these nodes (changed since last review + their 1-hop neighbors): ${scopeNodeIds.join(', ')}\n\n`
93
+ : `No previous review — analyze all nodes.\n\n`;
94
+
95
+ if (dismissedList.length > 0) {
96
+ prompt += `## Previously Dismissed (do NOT re-propose these exact tuples)\n`;
97
+ prompt += dismissedList.map((d: any) => ` - ${d}`).join('\n') + '\n\n';
98
+ }
99
+
100
+ prompt += `## Valid Relation Types\n${validRelations.join(', ')}\n\n`;
101
+ prompt += `## Valid Node Types\n${validNodeTypes.join(', ')}\n\n`;
102
+
103
+ prompt += `## Five Proposal Types\n`;
104
+ prompt += `1. add_edge — Add a missing edge between existing nodes\n`;
105
+ prompt += `2. add_node — Add a missing node (e.g. a shared design_pattern) with suggested edges\n`;
106
+ prompt += `3. remove_edge — Remove a stale edge that no longer reflects the architecture\n`;
107
+ prompt += `4. deprecate_node — Remove an obsolete node (list all edges that will be removed)\n`;
108
+ prompt += `5. update_description — Update a node description that references outdated concepts\n\n`;
109
+
110
+ prompt += `## Confidence Levels\n`;
111
+ prompt += `- high: A decision or migration node explicitly confirms the finding\n`;
112
+ prompt += `- medium: Inferred from context and relationships\n`;
113
+ prompt += `- low: Based on naming or convention patterns\n\n`;
114
+
115
+ prompt += `## Instructions\n`;
116
+ prompt += `1. Cross-reference findings against decision and tech_choice nodes to detect stale references.\n`;
117
+ prompt += `2. Be selective — only propose changes that add real value.\n`;
118
+ prompt += `3. Reference specific decision/tech_choice node IDs and names in your reasoning.\n`;
119
+ prompt += `4. Group related proposals by giving them the same batch_id string.\n`;
120
+ prompt += `5. Assign a confidence level to each proposal.\n\n`;
121
+
122
+ prompt += `## Output Format\n`;
123
+ prompt += `Respond with ONLY a JSON array. No other text.\n`;
124
+ prompt += `Proposal schemas:\n`;
125
+ prompt += `- {"type":"add_edge","from_id":"...","to_id":"...","relation":"...","confidence":"high|medium|low","batch_id":"optional","reasoning":"..."}\n`;
126
+ prompt += `- {"type":"add_node","proposed_node_type":"...","proposed_node_name":"...","confidence":"...","batch_id":"optional","reasoning":"...","suggested_edges":[{"from_id":"...","to_id":"NEW","relation":"..."}]}\n`;
127
+ prompt += `- {"type":"remove_edge","from_id":"...","to_id":"...","relation":"...","confidence":"...","batch_id":"optional","reasoning":"..."}\n`;
128
+ prompt += `- {"type":"deprecate_node","target_node_id":"...","affected_edges":[{"from":"...","relation":"...","to":"..."}],"confidence":"...","batch_id":"optional","reasoning":"..."}\n`;
129
+ prompt += `- {"type":"update_description","target_node_id":"...","proposed_description":"...","confidence":"...","batch_id":"optional","reasoning":"..."}\n\n`;
130
+ prompt += `If there are no proposals, respond with: []\n`;
131
+
132
+ return prompt;
133
+ };
134
+
135
+ export const runCoherenceReview = async (projectId?: string) => {
136
+ log('COHERENCE', '=== Starting coherence review ===');
137
+ const coherenceData = await readCoherenceData(projectId);
138
+ coherenceData.review_status = 'running';
139
+ coherenceData.partial_run = false;
140
+ await writeCoherenceData(coherenceData, projectId);
141
+
142
+ const collectedProposals: any[] = [];
143
+
144
+ try {
145
+ const graph = await readGraph(projectId);
146
+ coherenceProgress = { current: 0, total: graph.nodes.length, message: 'Building analysis prompt...' };
147
+
148
+ const prompt = buildCoherencePrompt(graph, coherenceData);
149
+ log('COHERENCE', `Prompt built — ${prompt.length} chars`);
150
+ coherenceProgress = { current: 0, total: 1, message: 'AI agent analyzing graph...' };
151
+
152
+ const output = await claudeService.spawnClaude(prompt, paths.dataDir, 'coherence-review');
153
+ log('COHERENCE', `Claude output received — ${output.length} chars`);
154
+ coherenceProgress = { current: 1, total: 1, message: 'Parsing results...' };
155
+
156
+ let jsonStr = output.trim();
157
+ const fenceMatch = jsonStr.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/);
158
+ if (fenceMatch) {
159
+ jsonStr = fenceMatch[1].trim();
160
+ }
161
+ const arrayMatch = jsonStr.match(/\[[\s\S]*\]/);
162
+ if (arrayMatch) {
163
+ jsonStr = arrayMatch[0];
164
+ }
165
+
166
+ let proposals;
167
+ try {
168
+ proposals = JSON.parse(jsonStr);
169
+ } catch (parseErr: any) {
170
+ log('COHERENCE', `Failed to parse Claude output as JSON: ${parseErr.message}`);
171
+ log('COHERENCE', `Raw output: ${output.slice(0, 500)}`);
172
+ coherenceData.review_status = 'idle';
173
+ await writeCoherenceData(coherenceData, projectId);
174
+ coherenceRunning = false;
175
+ coherenceProgress = { current: 0, total: 0, message: '' };
176
+ return;
177
+ }
178
+
179
+ if (!Array.isArray(proposals)) {
180
+ log('COHERENCE', `Claude output is not an array`);
181
+ coherenceData.review_status = 'idle';
182
+ await writeCoherenceData(coherenceData, projectId);
183
+ coherenceRunning = false;
184
+ coherenceProgress = { current: 0, total: 0, message: '' };
185
+ return;
186
+ }
187
+
188
+ for (const p of proposals) {
189
+ if (p.type === 'edge') p.type = 'add_edge';
190
+ if (p.type === 'node') p.type = 'add_node';
191
+ }
192
+
193
+ const dismissedKeys = new Set(
194
+ coherenceData.proposals
195
+ .filter((p: any) => p.status === 'dismissed')
196
+ .map((p: any) => getDismissalKey(p))
197
+ );
198
+
199
+ const now = new Date().toISOString();
200
+ for (const p of proposals) {
201
+ p.id = randomUUID().slice(0, 8);
202
+ p.status = 'pending';
203
+ p.created_at = now;
204
+ p.resolved_at = null;
205
+
206
+ const key = getDismissalKey(p);
207
+ if (dismissedKeys.has(key)) {
208
+ log('COHERENCE', `Skipping dismissed proposal: ${key}`);
209
+ continue;
210
+ }
211
+
212
+ if (p.type === 'deprecate_node' && p.target_node_id) {
213
+ const affectedEdges = graph.edges.filter(
214
+ (e: any) => e.from === p.target_node_id || e.to === p.target_node_id
215
+ );
216
+ p.affected_edges = affectedEdges.map((e: any) => ({
217
+ from: e.from, relation: e.relation, to: e.to
218
+ }));
219
+ }
220
+
221
+ collectedProposals.push(p);
222
+ }
223
+
224
+ const sorted = sortProposals(collectedProposals);
225
+ for (const p of sorted) {
226
+ coherenceData.proposals.push(p);
227
+ }
228
+
229
+ coherenceData.last_review_timestamp = now;
230
+ coherenceData.review_status = 'idle';
231
+ coherenceData.partial_run = false;
232
+ await writeCoherenceData(coherenceData, projectId);
233
+ log('COHERENCE', `=== Coherence review completed — ${sorted.length} proposals ===`);
234
+ } catch (err: any) {
235
+ log('COHERENCE', `Review FAILED: ${err.message}`);
236
+ if (collectedProposals.length > 0) {
237
+ const sorted = sortProposals(collectedProposals);
238
+ for (const p of sorted) {
239
+ coherenceData.proposals.push(p);
240
+ }
241
+ log('COHERENCE', `Saved ${sorted.length} partial proposals before failure`);
242
+ }
243
+ coherenceData.review_status = 'idle';
244
+ coherenceData.partial_run = true;
245
+ coherenceData.last_review_timestamp = new Date().toISOString();
246
+ await writeCoherenceData(coherenceData, projectId);
247
+ }
248
+ coherenceRunning = false;
249
+ coherenceProgress = { current: 0, total: 0, message: '' };
250
+ };
251
+
252
+ /**
253
+ * Execute the graph action for a proposal.
254
+ */
255
+ export const executeProposalAction = async (proposal: any, coherenceData: any, projectId?: string) => {
256
+ const { toolRunner, toolsDir, toolExt, dataDir } = paths;
257
+ const pidArgs = projectId ? ['--project-id', projectId] : [];
258
+
259
+ if (proposal.type === 'add_edge' || proposal.type === 'edge') {
260
+ await claudeService.spawnCommand(toolRunner, [
261
+ join(toolsDir, `add_edge${toolExt}`),
262
+ proposal.from_id, proposal.relation, proposal.to_id,
263
+ ...pidArgs,
264
+ ], dataDir);
265
+ log('COHERENCE', `Applied edge: ${proposal.from_id} --${proposal.relation}--> ${proposal.to_id}`);
266
+ } else if (proposal.type === 'add_node' || proposal.type === 'node') {
267
+ const result = await claudeService.spawnCommand(toolRunner, [
268
+ join(toolsDir, `add_node${toolExt}`),
269
+ '--type', proposal.proposed_node_type,
270
+ '--name', proposal.proposed_node_name,
271
+ '--description', proposal.reasoning,
272
+ ...pidArgs,
273
+ ], dataDir);
274
+ log('COHERENCE', `Created node: ${proposal.proposed_node_type} "${proposal.proposed_node_name}"`);
275
+
276
+ const idMatch = result.match(/([a-z_]+_\d+)/);
277
+ if (idMatch && proposal.suggested_edges) {
278
+ const newNodeId = idMatch[1];
279
+ for (const edge of proposal.suggested_edges) {
280
+ const fromId = edge.from_id === 'NEW' ? newNodeId : edge.from_id;
281
+ const toId = edge.to_id === 'NEW' ? newNodeId : edge.to_id;
282
+ try {
283
+ await claudeService.spawnCommand(toolRunner, [
284
+ join(toolsDir, `add_edge${toolExt}`),
285
+ fromId, edge.relation, toId,
286
+ ...pidArgs,
287
+ ], dataDir);
288
+ log('COHERENCE', `Applied suggested edge: ${fromId} --${edge.relation}--> ${toId}`);
289
+ } catch (edgeErr: any) {
290
+ log('COHERENCE', `Failed to apply suggested edge: ${edgeErr.message}`);
291
+ }
292
+ }
293
+ proposal.created_node_id = newNodeId;
294
+ }
295
+ } else if (proposal.type === 'remove_edge') {
296
+ await claudeService.spawnCommand(toolRunner, [
297
+ join(toolsDir, `remove_edge${toolExt}`),
298
+ proposal.from_id, proposal.relation, proposal.to_id,
299
+ ...pidArgs,
300
+ ], dataDir);
301
+ log('COHERENCE', `Removed edge: ${proposal.from_id} --${proposal.relation}--> ${proposal.to_id}`);
302
+ } else if (proposal.type === 'deprecate_node') {
303
+ const graph = await readGraph();
304
+ const affectedEdges = graph.edges.filter(
305
+ (e: any) => e.from === proposal.target_node_id || e.to === proposal.target_node_id
306
+ );
307
+ for (const edge of affectedEdges) {
308
+ try {
309
+ await removeEdgeDb(edge.from, edge.relation, edge.to);
310
+ log('COHERENCE', `Removed edge for deprecated node: ${edge.from} --${edge.relation}--> ${edge.to}`);
311
+ } catch (edgeErr: any) {
312
+ log('COHERENCE', `Failed to remove edge: ${edgeErr.message}`);
313
+ }
314
+ }
315
+ const db = getDb();
316
+ await db.delete(nodes).where(eq(nodes.id, proposal.target_node_id));
317
+ log('COHERENCE', `Deprecated node: ${proposal.target_node_id}`);
318
+
319
+ coherenceData.proposals = coherenceData.proposals.filter((p: any) => {
320
+ if (p.status !== 'dismissed') return true;
321
+ if (p.target_node_id === proposal.target_node_id) return false;
322
+ if (p.from_id === proposal.target_node_id || p.to_id === proposal.target_node_id) return false;
323
+ return true;
324
+ });
325
+ } else if (proposal.type === 'update_description') {
326
+ await patchNodeDb(proposal.target_node_id, {
327
+ body: proposal.proposed_description,
328
+ });
329
+ log('COHERENCE', `Updated description for node: ${proposal.target_node_id}`);
330
+ }
331
+ };
332
+
333
+ export const setCoherenceRunning = (value: boolean) => {
334
+ coherenceRunning = value;
335
+ };
336
+
337
+ export const setCoherenceProgress = (value: { current: number; total: number; message: string }) => {
338
+ coherenceProgress = value;
339
+ };
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Email service — sends transactional emails via Resend.
3
+ * Constructor DI: receives apiKey and fromAddress.
4
+ * Gracefully degrades when no API key is configured (logs warning instead of crashing).
5
+ */
6
+
7
+ import { Resend } from 'resend';
8
+
9
+ interface EmailServiceDeps {
10
+ apiKey: string;
11
+ fromAddress: string;
12
+ }
13
+
14
+ export class EmailService {
15
+ private readonly client: Resend | null;
16
+ private readonly fromAddress: string;
17
+
18
+ constructor({ apiKey, fromAddress }: EmailServiceDeps) {
19
+ this.client = apiKey ? new Resend(apiKey) : null;
20
+ this.fromAddress = fromAddress;
21
+ if (!apiKey) {
22
+ console.warn('[EMAIL] RESEND_API_KEY not set — email sending disabled');
23
+ }
24
+ }
25
+
26
+ sendPasswordResetEmail = async (to: string, resetUrl: string): Promise<void> => {
27
+ if (!this.client) {
28
+ console.warn(`[EMAIL] Would send password reset to ${to}: ${resetUrl}`);
29
+ return;
30
+ }
31
+
32
+ console.log(`[EMAIL] Sending password reset to ${to} from ${this.fromAddress}`);
33
+ const { data, error } = await this.client.emails.send({
34
+ from: this.fromAddress,
35
+ to,
36
+ subject: 'Reset your password',
37
+ html: `
38
+ <p>You requested a password reset.</p>
39
+ <p><a href="${resetUrl}">Click here to reset your password</a></p>
40
+ <p>This link expires in 1 hour. If you didn't request this, ignore this email.</p>
41
+ `,
42
+ });
43
+
44
+ if (error) {
45
+ console.error(`[EMAIL] Resend API error:`, error);
46
+ throw new Error(`Resend API error: ${error.message}`);
47
+ }
48
+ console.log(`[EMAIL] Password reset sent successfully, id=${data?.id}`);
49
+ };
50
+
51
+ sendInvitationEmail = async (to: string, invitationUrl: string): Promise<void> => {
52
+ if (!this.client) {
53
+ console.warn(`[EMAIL] Would send invitation to ${to}: ${invitationUrl}`);
54
+ return;
55
+ }
56
+
57
+ console.log(`[EMAIL] Sending invitation to ${to} from ${this.fromAddress}`);
58
+ const { data, error } = await this.client.emails.send({
59
+ from: this.fromAddress,
60
+ to,
61
+ subject: "You're invited to join",
62
+ html: `
63
+ <p>You've been invited to join the Product Interview System.</p>
64
+ <p><a href="${invitationUrl}">Click here to accept your invitation</a></p>
65
+ <p>This link expires in 48 hours.</p>
66
+ `,
67
+ });
68
+
69
+ if (error) {
70
+ console.error(`[EMAIL] Resend API error:`, error);
71
+ throw new Error(`Resend API error: ${error.message}`);
72
+ }
73
+ console.log(`[EMAIL] Invitation sent successfully, id=${data?.id}`);
74
+ };
75
+ }