prjct-cli 1.14.0 → 1.15.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,46 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.15.0] - 2026-02-09
4
+
5
+ ### Features
6
+
7
+ - replace hardcoded memory domain tags with semantic matching (PRJ-300) (#157)
8
+
9
+
10
+ ## [1.14.1] - 2026-02-09
11
+
12
+ ### Improved
13
+ - **Semantic memory domain matching** (PRJ-300): Memory retrieval now uses two-pass scoring (exact MEMORY_TAG match + semantic keyword match) instead of exact-only tag matching. Unknown domains like "uxui" are resolved to canonical domains ("frontend") via SEMANTIC_DOMAIN_KEYWORDS map.
14
+ - **Tech stack normalizer**: New `tech-normalizer.ts` module handles framework name normalization, compound names ("React + TypeScript"), framework families (Next.js → React), and alias resolution (nextjs → next.js).
15
+ - **Anti-hallucination dedup**: Tech stack entries in the anti-hallucination block are now deduplicated using normalized matching, preventing duplicates like "React" and "react" or "Next.js" and "nextjs".
16
+ - **Sealed analysis priority**: Prompt builder now uses sealed analysis frameworks as primary tech stack source, falling back to repo conventions.
17
+
18
+ ### Implementation Details
19
+ - Expanded `TaskDomain` type from fixed union to `KnownDomain | (string & {})` for forward compatibility while preserving autocomplete
20
+ - `DOMAIN_TAG_MAP` now includes TECH_STACK for frontend/database domains (previously missing)
21
+ - `SEMANTIC_DOMAIN_KEYWORDS` maps 80+ keywords across 7 domains for fuzzy domain resolution
22
+ - `resolveCanonicalDomains()` exported for testability — resolves arbitrary strings to known domains
23
+ - `normalizeFrameworkName()` handles 15 aliases; `FRAMEWORK_FAMILIES` maps 12 meta-frameworks to base frameworks
24
+ - `extractTechNames()` splits compound tech strings on +, /, commas, parentheses, "with", "and"
25
+
26
+ ### Learnings
27
+ - Two-pass scoring (exact 10pts + semantic 5pts) gives gradual relevance instead of binary match/no-match
28
+ - TypeScript's `KnownDomain | (string & {})` pattern preserves autocomplete for known values while accepting any string
29
+ - Parentheses in compound names need comma replacement (not space) — otherwise adjacent words merge into a single token
30
+
31
+ ### Test Plan
32
+
33
+ #### For QA
34
+ 1. `bun test core/__tests__/agentic/semantic-matching.test.ts` — domain resolution (uxui→frontend, api→backend, infra→devops)
35
+ 2. `bun test core/__tests__/agentic/tech-normalizer.test.ts` — normalization, compound names, framework families, dedup
36
+ 3. `bun test core/__tests__/agentic/prompt-assembly.test.ts` — anti-hallucination block still renders correctly
37
+ 4. `bun test` — 848 tests pass, 0 fail
38
+
39
+ #### For Users
40
+ - Memory retrieval automatically surfaces related memories across domains
41
+ - No user action needed — improvements are automatic
42
+ - No breaking changes
43
+
3
44
  ## [1.14.0] - 2026-02-09
4
45
 
5
46
  ### Features
@@ -0,0 +1,131 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import {
3
+ DOMAIN_TAG_MAP,
4
+ resolveCanonicalDomains,
5
+ SEMANTIC_DOMAIN_KEYWORDS,
6
+ } from '../../agentic/memory-system'
7
+ import { KNOWN_DOMAINS, MEMORY_TAGS } from '../../types/memory'
8
+
9
+ describe('semantic domain matching (PRJ-300)', () => {
10
+ describe('resolveCanonicalDomains', () => {
11
+ it('should pass through known domains unchanged', () => {
12
+ expect(resolveCanonicalDomains('frontend')).toEqual(['frontend'])
13
+ expect(resolveCanonicalDomains('backend')).toEqual(['backend'])
14
+ expect(resolveCanonicalDomains('devops')).toEqual(['devops'])
15
+ expect(resolveCanonicalDomains('testing')).toEqual(['testing'])
16
+ expect(resolveCanonicalDomains('database')).toEqual(['database'])
17
+ expect(resolveCanonicalDomains('general')).toEqual(['general'])
18
+ })
19
+
20
+ it('should resolve "uxui" to frontend', () => {
21
+ const result = resolveCanonicalDomains('uxui')
22
+ expect(result).toContain('frontend')
23
+ })
24
+
25
+ it('should resolve "ui" to frontend', () => {
26
+ const result = resolveCanonicalDomains('ui')
27
+ expect(result).toContain('frontend')
28
+ })
29
+
30
+ it('should resolve "api" to backend', () => {
31
+ const result = resolveCanonicalDomains('api')
32
+ expect(result).toContain('backend')
33
+ })
34
+
35
+ it('should resolve "ml-pipeline" to backend', () => {
36
+ const result = resolveCanonicalDomains('ml-pipeline')
37
+ // "pipeline" matches devops, so it should at least resolve
38
+ expect(result.length).toBeGreaterThan(0)
39
+ expect(result).not.toEqual(['general'])
40
+ })
41
+
42
+ it('should resolve "infra" to devops', () => {
43
+ const result = resolveCanonicalDomains('infra')
44
+ expect(result).toContain('devops')
45
+ })
46
+
47
+ it('should resolve "docker" to devops', () => {
48
+ const result = resolveCanonicalDomains('docker')
49
+ expect(result).toContain('devops')
50
+ })
51
+
52
+ it('should resolve "schema" to database', () => {
53
+ const result = resolveCanonicalDomains('schema')
54
+ expect(result).toContain('database')
55
+ })
56
+
57
+ it('should resolve "css" to frontend', () => {
58
+ const result = resolveCanonicalDomains('css')
59
+ expect(result).toContain('frontend')
60
+ })
61
+
62
+ it('should resolve "e2e" to testing', () => {
63
+ const result = resolveCanonicalDomains('e2e')
64
+ expect(result).toContain('testing')
65
+ })
66
+
67
+ it('should fallback to general for truly unknown domains', () => {
68
+ expect(resolveCanonicalDomains('quantum-computing')).toEqual(['general'])
69
+ expect(resolveCanonicalDomains('astrology')).toEqual(['general'])
70
+ })
71
+
72
+ it('should handle case-insensitive and separator-insensitive', () => {
73
+ expect(resolveCanonicalDomains('UI')).toContain('frontend')
74
+ expect(resolveCanonicalDomains('API')).toContain('backend')
75
+ expect(resolveCanonicalDomains('e_2_e')).toContain('testing')
76
+ })
77
+ })
78
+
79
+ describe('DOMAIN_TAG_MAP', () => {
80
+ it('should include TECH_STACK for frontend (improved from PRJ-107)', () => {
81
+ expect(DOMAIN_TAG_MAP.frontend).toContain(MEMORY_TAGS.TECH_STACK)
82
+ })
83
+
84
+ it('should include TECH_STACK for database', () => {
85
+ expect(DOMAIN_TAG_MAP.database).toContain(MEMORY_TAGS.TECH_STACK)
86
+ })
87
+
88
+ it('should include DEPENDENCIES for testing', () => {
89
+ expect(DOMAIN_TAG_MAP.testing).toContain(MEMORY_TAGS.DEPENDENCIES)
90
+ })
91
+
92
+ it('should include all MEMORY_TAGS for general', () => {
93
+ const allTags = Object.values(MEMORY_TAGS)
94
+ for (const tag of allTags) {
95
+ expect(DOMAIN_TAG_MAP.general).toContain(tag)
96
+ }
97
+ })
98
+
99
+ it('should have mappings for all known domains', () => {
100
+ for (const domain of KNOWN_DOMAINS) {
101
+ expect(DOMAIN_TAG_MAP[domain]).toBeDefined()
102
+ expect(DOMAIN_TAG_MAP[domain].length).toBeGreaterThan(0)
103
+ }
104
+ })
105
+ })
106
+
107
+ describe('SEMANTIC_DOMAIN_KEYWORDS', () => {
108
+ it('should map uxui-related keywords to frontend', () => {
109
+ expect(SEMANTIC_DOMAIN_KEYWORDS.frontend).toContain('uxui')
110
+ expect(SEMANTIC_DOMAIN_KEYWORDS.frontend).toContain('ui')
111
+ expect(SEMANTIC_DOMAIN_KEYWORDS.frontend).toContain('ux')
112
+ expect(SEMANTIC_DOMAIN_KEYWORDS.frontend).toContain('css')
113
+ })
114
+
115
+ it('should map CI/CD keywords to devops', () => {
116
+ expect(SEMANTIC_DOMAIN_KEYWORDS.devops).toContain('ci')
117
+ expect(SEMANTIC_DOMAIN_KEYWORDS.devops).toContain('cd')
118
+ expect(SEMANTIC_DOMAIN_KEYWORDS.devops).toContain('docker')
119
+ expect(SEMANTIC_DOMAIN_KEYWORDS.devops).toContain('kubernetes')
120
+ })
121
+
122
+ it('should have keywords for all known domains except general', () => {
123
+ for (const domain of KNOWN_DOMAINS) {
124
+ expect(SEMANTIC_DOMAIN_KEYWORDS[domain]).toBeDefined()
125
+ if (domain !== 'general') {
126
+ expect(SEMANTIC_DOMAIN_KEYWORDS[domain].length).toBeGreaterThan(0)
127
+ }
128
+ }
129
+ })
130
+ })
131
+ })
@@ -0,0 +1,136 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import {
3
+ deduplicateTechStack,
4
+ extractTechNames,
5
+ getFrameworkFamily,
6
+ matchesTech,
7
+ normalizeFrameworkName,
8
+ } from '../../agentic/tech-normalizer'
9
+
10
+ describe('tech-normalizer', () => {
11
+ describe('normalizeFrameworkName', () => {
12
+ it('should lowercase and trim', () => {
13
+ expect(normalizeFrameworkName(' TypeScript ')).toBe('typescript')
14
+ expect(normalizeFrameworkName('React')).toBe('react')
15
+ })
16
+
17
+ it('should resolve aliases', () => {
18
+ expect(normalizeFrameworkName('NodeJS')).toBe('node')
19
+ expect(normalizeFrameworkName('ts')).toBe('typescript')
20
+ expect(normalizeFrameworkName('js')).toBe('javascript')
21
+ expect(normalizeFrameworkName('pg')).toBe('postgres')
22
+ expect(normalizeFrameworkName('postgresql')).toBe('postgres')
23
+ })
24
+
25
+ it('should preserve dotted names', () => {
26
+ expect(normalizeFrameworkName('Next.js')).toBe('next.js')
27
+ expect(normalizeFrameworkName('Vue.js')).toBe('vue')
28
+ })
29
+
30
+ it('should handle nextjs alias', () => {
31
+ expect(normalizeFrameworkName('nextjs')).toBe('next.js')
32
+ expect(normalizeFrameworkName('nuxtjs')).toBe('nuxt.js')
33
+ })
34
+ })
35
+
36
+ describe('extractTechNames', () => {
37
+ it('should split on + separator', () => {
38
+ expect(extractTechNames('React + TypeScript')).toEqual(['react', 'typescript'])
39
+ })
40
+
41
+ it('should extract from parentheses', () => {
42
+ expect(extractTechNames('Next.js (React)')).toEqual(['next.js', 'react'])
43
+ })
44
+
45
+ it('should split on "with"', () => {
46
+ expect(extractTechNames('Hono with Zod')).toEqual(['hono', 'zod'])
47
+ })
48
+
49
+ it('should split on commas', () => {
50
+ expect(extractTechNames('React, Vue, Angular')).toEqual(['react', 'vue', 'angular'])
51
+ })
52
+
53
+ it('should handle single name', () => {
54
+ expect(extractTechNames('React')).toEqual(['react'])
55
+ })
56
+ })
57
+
58
+ describe('getFrameworkFamily', () => {
59
+ it('should resolve meta-frameworks to base', () => {
60
+ expect(getFrameworkFamily('next.js')).toBe('react')
61
+ expect(getFrameworkFamily('Next.js')).toBe('react')
62
+ expect(getFrameworkFamily('Remix')).toBe('react')
63
+ expect(getFrameworkFamily('Gatsby')).toBe('react')
64
+ })
65
+
66
+ it('should resolve Vue meta-frameworks', () => {
67
+ expect(getFrameworkFamily('nuxt')).toBe('vue')
68
+ expect(getFrameworkFamily('Nuxt.js')).toBe('vue')
69
+ })
70
+
71
+ it('should resolve Svelte meta-framework', () => {
72
+ expect(getFrameworkFamily('SvelteKit')).toBe('svelte')
73
+ })
74
+
75
+ it('should return name itself for base frameworks', () => {
76
+ expect(getFrameworkFamily('React')).toBe('react')
77
+ expect(getFrameworkFamily('Express')).toBe('express')
78
+ expect(getFrameworkFamily('Hono')).toBe('hono')
79
+ })
80
+ })
81
+
82
+ describe('matchesTech', () => {
83
+ it('should match exact names (case-insensitive)', () => {
84
+ expect(matchesTech('React', 'react')).toBe(true)
85
+ expect(matchesTech('react', 'React')).toBe(true)
86
+ })
87
+
88
+ it('should match via framework family', () => {
89
+ expect(matchesTech('Next.js', 'react')).toBe(true)
90
+ expect(matchesTech('Nuxt', 'vue')).toBe(true)
91
+ expect(matchesTech('SvelteKit', 'svelte')).toBe(true)
92
+ })
93
+
94
+ it('should match compound names', () => {
95
+ expect(matchesTech('React + TypeScript', 'react')).toBe(true)
96
+ expect(matchesTech('React + TypeScript', 'typescript')).toBe(true)
97
+ })
98
+
99
+ it('should match parenthesized names', () => {
100
+ expect(matchesTech('Next.js (React)', 'react')).toBe(true)
101
+ expect(matchesTech('Next.js (React)', 'next.js')).toBe(true)
102
+ })
103
+
104
+ it('should not match unrelated frameworks', () => {
105
+ expect(matchesTech('Vue', 'react')).toBe(false)
106
+ expect(matchesTech('Angular', 'react')).toBe(false)
107
+ })
108
+ })
109
+
110
+ describe('deduplicateTechStack', () => {
111
+ it('should remove case-insensitive duplicates', () => {
112
+ expect(deduplicateTechStack(['React', 'react', 'REACT'])).toEqual(['React'])
113
+ })
114
+
115
+ it('should remove alias duplicates', () => {
116
+ expect(deduplicateTechStack(['TypeScript', 'ts'])).toEqual(['TypeScript'])
117
+ expect(deduplicateTechStack(['Node.js', 'nodejs'])).toEqual(['Node.js'])
118
+ })
119
+
120
+ it('should preserve unique entries', () => {
121
+ expect(deduplicateTechStack(['React', 'Next.js', 'TypeScript'])).toEqual([
122
+ 'React',
123
+ 'Next.js',
124
+ 'TypeScript',
125
+ ])
126
+ })
127
+
128
+ it('should preserve first occurrence casing', () => {
129
+ expect(deduplicateTechStack(['Next.js', 'nextjs', 'NEXT.JS'])).toEqual(['Next.js'])
130
+ })
131
+
132
+ it('should handle empty array', () => {
133
+ expect(deduplicateTechStack([])).toEqual([])
134
+ })
135
+ })
136
+ })
@@ -19,6 +19,7 @@
19
19
  */
20
20
 
21
21
  import { z } from 'zod'
22
+ import { deduplicateTechStack } from './tech-normalizer'
22
23
 
23
24
  // =============================================================================
24
25
  // Schema
@@ -85,25 +86,19 @@ export function buildAntiHallucinationBlock(truth: ProjectGroundTruth): string {
85
86
 
86
87
  parts.push('## CONSTRAINTS (Read Before Acting)\n')
87
88
 
88
- // 1. Explicit availability (enriched by sealed analysis — PRJ-260)
89
- const available: string[] = []
90
- if (truth.language) available.push(truth.language)
91
- if (truth.framework) available.push(truth.framework)
89
+ // 1. Explicit availability (enriched by sealed analysis — PRJ-260, PRJ-300)
90
+ // Use normalized deduplication to prevent "React" and "react" appearing twice,
91
+ // and to handle aliases like "Next.js" vs "nextjs".
92
+ const rawAvailable: string[] = []
93
+ if (truth.language) rawAvailable.push(truth.language)
94
+ if (truth.framework) rawAvailable.push(truth.framework)
92
95
  const techStack = truth.techStack ?? []
93
- available.push(...techStack.filter((t) => t !== truth.framework))
94
- // Merge languages/frameworks from sealed analysis (deduped)
96
+ rawAvailable.push(...techStack)
97
+ // Merge languages/frameworks from sealed analysis
95
98
  const analysisLangs = truth.analysisLanguages ?? []
96
99
  const analysisFrameworks = truth.analysisFrameworks ?? []
97
- for (const lang of analysisLangs) {
98
- if (!available.some((a) => a.toLowerCase() === lang.toLowerCase())) {
99
- available.push(lang)
100
- }
101
- }
102
- for (const fw of analysisFrameworks) {
103
- if (!available.some((a) => a.toLowerCase() === fw.toLowerCase())) {
104
- available.push(fw)
105
- }
106
- }
100
+ rawAvailable.push(...analysisLangs, ...analysisFrameworks)
101
+ const available = deduplicateTechStack(rawAvailable)
107
102
 
108
103
  if (available.length > 0) {
109
104
  parts.push(`AVAILABLE in this project: ${available.join(', ')}`)
@@ -26,6 +26,7 @@ export type {
26
26
  Decision,
27
27
  HistoryEntry,
28
28
  HistoryEventType,
29
+ KnownDomain,
29
30
  Memory,
30
31
  MemoryContext,
31
32
  MemoryContextParams,
@@ -40,11 +41,12 @@ export type {
40
41
  Workflow,
41
42
  } from '../types/memory'
42
43
 
43
- export { calculateConfidence, MEMORY_TAGS } from '../types/memory'
44
+ export { calculateConfidence, KNOWN_DOMAINS, MEMORY_TAGS } from '../types/memory'
44
45
 
45
46
  import type {
46
47
  HistoryEntry,
47
48
  HistoryEventType,
49
+ KnownDomain,
48
50
  Memory,
49
51
  MemoryContext,
50
52
  MemoryDatabase,
@@ -58,7 +60,167 @@ import type {
58
60
  Workflow,
59
61
  } from '../types/memory'
60
62
 
61
- import { calculateConfidence, MEMORY_TAGS } from '../types/memory'
63
+ import { calculateConfidence, KNOWN_DOMAINS, MEMORY_TAGS } from '../types/memory'
64
+
65
+ // =============================================================================
66
+ // Semantic Domain Mapping (PRJ-300)
67
+ // =============================================================================
68
+
69
+ /**
70
+ * Map each known domain to its relevant MEMORY_TAGS.
71
+ * More comprehensive than the previous mapping — includes TECH_STACK and
72
+ * DEPENDENCIES where they apply.
73
+ */
74
+ export const DOMAIN_TAG_MAP: Record<string, MemoryTag[]> = {
75
+ frontend: [
76
+ MEMORY_TAGS.CODE_STYLE,
77
+ MEMORY_TAGS.FILE_STRUCTURE,
78
+ MEMORY_TAGS.ARCHITECTURE,
79
+ MEMORY_TAGS.TECH_STACK,
80
+ ],
81
+ backend: [
82
+ MEMORY_TAGS.CODE_STYLE,
83
+ MEMORY_TAGS.ARCHITECTURE,
84
+ MEMORY_TAGS.DEPENDENCIES,
85
+ MEMORY_TAGS.TECH_STACK,
86
+ ],
87
+ devops: [
88
+ MEMORY_TAGS.SHIP_WORKFLOW,
89
+ MEMORY_TAGS.TEST_BEHAVIOR,
90
+ MEMORY_TAGS.DEPENDENCIES,
91
+ MEMORY_TAGS.ARCHITECTURE,
92
+ ],
93
+ docs: [MEMORY_TAGS.CODE_STYLE, MEMORY_TAGS.NAMING_CONVENTION, MEMORY_TAGS.FILE_STRUCTURE],
94
+ testing: [MEMORY_TAGS.TEST_BEHAVIOR, MEMORY_TAGS.CODE_STYLE, MEMORY_TAGS.DEPENDENCIES],
95
+ database: [
96
+ MEMORY_TAGS.ARCHITECTURE,
97
+ MEMORY_TAGS.NAMING_CONVENTION,
98
+ MEMORY_TAGS.TECH_STACK,
99
+ MEMORY_TAGS.DEPENDENCIES,
100
+ ],
101
+ general: Object.values(MEMORY_TAGS) as MemoryTag[],
102
+ }
103
+
104
+ /**
105
+ * Semantic keywords for each domain.
106
+ * Used to resolve unknown domain strings (e.g., "uxui" → frontend)
107
+ * and for partial scoring when memory tags relate semantically.
108
+ * @see PRJ-300
109
+ */
110
+ export const SEMANTIC_DOMAIN_KEYWORDS: Record<string, string[]> = {
111
+ frontend: [
112
+ 'ui',
113
+ 'ux',
114
+ 'uxui',
115
+ 'css',
116
+ 'styling',
117
+ 'component',
118
+ 'layout',
119
+ 'design',
120
+ 'responsive',
121
+ 'react',
122
+ 'vue',
123
+ 'svelte',
124
+ 'angular',
125
+ 'html',
126
+ 'tailwind',
127
+ 'sass',
128
+ 'web',
129
+ 'accessibility',
130
+ 'a11y',
131
+ ],
132
+ backend: [
133
+ 'api',
134
+ 'server',
135
+ 'route',
136
+ 'endpoint',
137
+ 'rest',
138
+ 'graphql',
139
+ 'middleware',
140
+ 'worker',
141
+ 'queue',
142
+ 'auth',
143
+ 'hono',
144
+ 'express',
145
+ 'service',
146
+ 'microservice',
147
+ ],
148
+ devops: [
149
+ 'ci',
150
+ 'cd',
151
+ 'docker',
152
+ 'kubernetes',
153
+ 'deploy',
154
+ 'infra',
155
+ 'infrastructure',
156
+ 'monitoring',
157
+ 'cloud',
158
+ 'aws',
159
+ 'gcp',
160
+ 'azure',
161
+ 'pipeline',
162
+ 'helm',
163
+ 'terraform',
164
+ ],
165
+ docs: ['documentation', 'readme', 'guide', 'tutorial', 'wiki', 'changelog', 'jsdoc', 'typedoc'],
166
+ testing: [
167
+ 'test',
168
+ 'spec',
169
+ 'e2e',
170
+ 'unit',
171
+ 'integration',
172
+ 'coverage',
173
+ 'mock',
174
+ 'vitest',
175
+ 'jest',
176
+ 'playwright',
177
+ 'cypress',
178
+ ],
179
+ database: [
180
+ 'db',
181
+ 'sql',
182
+ 'schema',
183
+ 'migration',
184
+ 'query',
185
+ 'orm',
186
+ 'prisma',
187
+ 'mongo',
188
+ 'postgres',
189
+ 'redis',
190
+ 'drizzle',
191
+ 'sqlite',
192
+ ],
193
+ general: [],
194
+ }
195
+
196
+ /**
197
+ * Resolve a domain string to canonical known domain(s).
198
+ * Known domains pass through; unknown domains are matched via semantic keywords.
199
+ * Exported for testing.
200
+ * @see PRJ-300
201
+ */
202
+ export function resolveCanonicalDomains(domain: string): KnownDomain[] {
203
+ // Exact match
204
+ if ((KNOWN_DOMAINS as readonly string[]).includes(domain)) {
205
+ return [domain as KnownDomain]
206
+ }
207
+
208
+ // Semantic resolution — find canonical domains whose keywords match
209
+ const normalized = domain.toLowerCase().replace(/[-_\s]/g, '')
210
+ const matches: KnownDomain[] = []
211
+
212
+ for (const [canonical, keywords] of Object.entries(SEMANTIC_DOMAIN_KEYWORDS)) {
213
+ if (canonical === 'general') continue
214
+ for (const kw of keywords) {
215
+ if (normalized.includes(kw) || kw.includes(normalized)) {
216
+ matches.push(canonical as KnownDomain)
217
+ break
218
+ }
219
+ }
220
+ }
221
+
222
+ return matches.length > 0 ? matches : ['general']
223
+ }
62
224
 
63
225
  // =============================================================================
64
226
  // Base Store
@@ -858,11 +1020,9 @@ export class SemanticMemories extends CachedStore<MemoryDatabase> {
858
1020
  userTriggered: 0,
859
1021
  }
860
1022
 
861
- // Domain match scoring (0-25 points)
1023
+ // Domain match scoring (0-25 points) — semantic matching (PRJ-300)
862
1024
  if (query.taskDomain) {
863
- const domainTags = this._getDomainTags(query.taskDomain)
864
- const matchingTags = (memory.tags || []).filter((tag) => domainTags.includes(tag))
865
- breakdown.domainMatch = Math.min(25, matchingTags.length * 10)
1025
+ breakdown.domainMatch = this._getSemanticDomainScore(query.taskDomain, memory.tags || [])
866
1026
  }
867
1027
 
868
1028
  // Tag match from command context (0-20 points)
@@ -943,25 +1103,66 @@ export class SemanticMemories extends CachedStore<MemoryDatabase> {
943
1103
  }
944
1104
 
945
1105
  /**
946
- * Map task domain to relevant memory tags.
947
- * @see PRJ-107
1106
+ * Compute semantic domain match score (0-25 points).
1107
+ *
1108
+ * Two-pass scoring:
1109
+ * 1. Exact MEMORY_TAG match: memory tag in domain's tag list → 10 pts each
1110
+ * 2. Semantic match: memory tag relates to domain via keywords → 5 pts each
1111
+ *
1112
+ * Unknown domains are resolved to canonical domain(s) via SEMANTIC_DOMAIN_KEYWORDS.
1113
+ * @see PRJ-107, PRJ-300
948
1114
  */
949
- private _getDomainTags(domain: TaskDomain): MemoryTag[] {
950
- const domainTagMap: Record<TaskDomain, MemoryTag[]> = {
951
- frontend: [MEMORY_TAGS.CODE_STYLE, MEMORY_TAGS.FILE_STRUCTURE, MEMORY_TAGS.ARCHITECTURE],
952
- backend: [
953
- MEMORY_TAGS.CODE_STYLE,
954
- MEMORY_TAGS.ARCHITECTURE,
955
- MEMORY_TAGS.DEPENDENCIES,
956
- MEMORY_TAGS.TECH_STACK,
957
- ],
958
- devops: [MEMORY_TAGS.SHIP_WORKFLOW, MEMORY_TAGS.TEST_BEHAVIOR, MEMORY_TAGS.DEPENDENCIES],
959
- docs: [MEMORY_TAGS.CODE_STYLE, MEMORY_TAGS.NAMING_CONVENTION],
960
- testing: [MEMORY_TAGS.TEST_BEHAVIOR, MEMORY_TAGS.CODE_STYLE],
961
- database: [MEMORY_TAGS.ARCHITECTURE, MEMORY_TAGS.NAMING_CONVENTION],
962
- general: Object.values(MEMORY_TAGS) as MemoryTag[],
1115
+ private _getSemanticDomainScore(domain: TaskDomain, memoryTags: string[]): number {
1116
+ // Resolve to canonical domain(s)
1117
+ const canonicals = this._resolveCanonicalDomains(domain)
1118
+ if (canonicals.length === 0) return 0
1119
+
1120
+ // Collect all relevant MEMORY_TAGS for the canonical domains
1121
+ const relevantTags = new Set<string>()
1122
+ for (const canonical of canonicals) {
1123
+ const tags = DOMAIN_TAG_MAP[canonical]
1124
+ if (tags) {
1125
+ for (const tag of tags) relevantTags.add(tag)
1126
+ }
1127
+ }
1128
+
1129
+ // Collect semantic keywords for the canonical domains
1130
+ const domainKeywords = new Set<string>()
1131
+ for (const canonical of canonicals) {
1132
+ const keywords = SEMANTIC_DOMAIN_KEYWORDS[canonical]
1133
+ if (keywords) {
1134
+ for (const kw of keywords) domainKeywords.add(kw)
1135
+ }
1136
+ }
1137
+
1138
+ let score = 0
1139
+
1140
+ for (const tag of memoryTags) {
1141
+ // Pass 1: exact MEMORY_TAG match (10 pts)
1142
+ if (relevantTags.has(tag)) {
1143
+ score += 10
1144
+ continue
1145
+ }
1146
+ // Pass 2: semantic keyword match (5 pts)
1147
+ const normalized = tag.toLowerCase().replace(/[-_\s]/g, '')
1148
+ for (const kw of domainKeywords) {
1149
+ if (normalized.includes(kw) || kw.includes(normalized)) {
1150
+ score += 5
1151
+ break
1152
+ }
1153
+ }
963
1154
  }
964
- return domainTagMap[domain] || []
1155
+
1156
+ return Math.min(25, score)
1157
+ }
1158
+
1159
+ /**
1160
+ * Resolve a domain string to canonical known domain(s).
1161
+ * Delegates to module-level resolveCanonicalDomains().
1162
+ * @see PRJ-300
1163
+ */
1164
+ private _resolveCanonicalDomains(domain: TaskDomain): KnownDomain[] {
1165
+ return resolveCanonicalDomains(domain)
965
1166
  }
966
1167
 
967
1168
  /**
@@ -39,6 +39,7 @@ import {
39
39
  InjectionBudgetTracker,
40
40
  truncateToTokenBudget,
41
41
  } from './injection-validator'
42
+ import { deduplicateTechStack } from './tech-normalizer'
42
43
  import type { TokenBudgetCoordinator } from './token-budget'
43
44
 
44
45
  // =============================================================================
@@ -689,10 +690,17 @@ class PromptBuilder {
689
690
 
690
691
  if (projectPath) {
691
692
  const sa = orchestratorContext?.sealedAnalysis
693
+ // PRJ-300: prefer sealed analysis frameworks as primary tech stack,
694
+ // falling back to repo conventions. Deduplicate with normalized matching.
695
+ const rawStack = [
696
+ ...(sa?.frameworks || []),
697
+ ...(orchestratorContext?.project?.conventions || []),
698
+ ]
692
699
  const groundTruth: ProjectGroundTruth = {
693
700
  projectPath,
694
701
  language: orchestratorContext?.project?.ecosystem,
695
- techStack: orchestratorContext?.project?.conventions || [],
702
+ framework: sa?.frameworks?.[0],
703
+ techStack: deduplicateTechStack(rawStack),
696
704
  domains: this.extractDomains(state),
697
705
  fileCount: context.files?.length || context.filteredSize || 0,
698
706
  availableAgents: orchestratorContext?.agents?.map((a) => a.name) || [],
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Tech Normalizer (PRJ-300)
3
+ *
4
+ * Normalized matching for tech stack / framework names.
5
+ * Handles:
6
+ * - Compound names: "React + TypeScript" → ["react", "typescript"]
7
+ * - Framework aliases: "nextjs" → "next.js"
8
+ * - Framework families: "Next.js" → react family
9
+ * - Case-insensitive, whitespace-insensitive matching
10
+ */
11
+
12
+ // =============================================================================
13
+ // Framework Families
14
+ // =============================================================================
15
+
16
+ /**
17
+ * Map framework names to their family (meta-framework → base framework).
18
+ * Used to verify that "Next.js" matches expected "react".
19
+ */
20
+ const FRAMEWORK_FAMILIES: Record<string, string> = {
21
+ 'next.js': 'react',
22
+ nextjs: 'react',
23
+ remix: 'react',
24
+ gatsby: 'react',
25
+ 'react native': 'react',
26
+ expo: 'react',
27
+ nuxt: 'vue',
28
+ 'nuxt.js': 'vue',
29
+ nuxtjs: 'vue',
30
+ sveltekit: 'svelte',
31
+ analog: 'angular',
32
+ astro: 'multi',
33
+ vite: 'multi',
34
+ }
35
+
36
+ /**
37
+ * Aliases that normalize to a canonical name.
38
+ */
39
+ const FRAMEWORK_ALIASES: Record<string, string> = {
40
+ nextjs: 'next.js',
41
+ nuxtjs: 'nuxt.js',
42
+ expressjs: 'express',
43
+ fastifyjs: 'fastify',
44
+ 'react.js': 'react',
45
+ 'vue.js': 'vue',
46
+ 'svelte.js': 'svelte',
47
+ 'angular.js': 'angular',
48
+ angularjs: 'angular',
49
+ 'node.js': 'node',
50
+ nodejs: 'node',
51
+ ts: 'typescript',
52
+ js: 'javascript',
53
+ pg: 'postgres',
54
+ postgresql: 'postgres',
55
+ mongo: 'mongodb',
56
+ }
57
+
58
+ // =============================================================================
59
+ // Core Functions
60
+ // =============================================================================
61
+
62
+ /**
63
+ * Normalize a single framework/tech name.
64
+ * Strips whitespace, lowercases, resolves aliases.
65
+ *
66
+ * @example
67
+ * normalizeFrameworkName("Next.js") → "next.js"
68
+ * normalizeFrameworkName(" TypeScript ") → "typescript"
69
+ * normalizeFrameworkName("NodeJS") → "node"
70
+ */
71
+ export function normalizeFrameworkName(name: string): string {
72
+ const trimmed = name.trim().toLowerCase()
73
+
74
+ // Check aliases
75
+ const aliasKey = trimmed.replace(/[.\s-]/g, '')
76
+ if (FRAMEWORK_ALIASES[aliasKey]) {
77
+ return FRAMEWORK_ALIASES[aliasKey]
78
+ }
79
+ if (FRAMEWORK_ALIASES[trimmed]) {
80
+ return FRAMEWORK_ALIASES[trimmed]
81
+ }
82
+
83
+ return trimmed
84
+ }
85
+
86
+ /**
87
+ * Extract individual tech names from a compound string.
88
+ * Handles separators: +, /, comma, parentheses, "with", "and".
89
+ *
90
+ * @example
91
+ * extractTechNames("React + TypeScript") → ["react", "typescript"]
92
+ * extractTechNames("Next.js (React)") → ["next.js", "react"]
93
+ * extractTechNames("Hono with Zod") → ["hono", "zod"]
94
+ */
95
+ export function extractTechNames(compound: string): string[] {
96
+ // Replace parentheses with comma separators to split them out
97
+ const parts = compound
98
+ .replace(/[()]/g, ',')
99
+ .split(/[+/,]|\bwith\b|\band\b/i)
100
+ .map((s) => s.trim())
101
+ .filter((s) => s.length > 0)
102
+
103
+ return parts.map(normalizeFrameworkName)
104
+ }
105
+
106
+ /**
107
+ * Get the framework family for a tech name.
108
+ * Returns the base framework or the name itself if no family exists.
109
+ *
110
+ * @example
111
+ * getFrameworkFamily("next.js") → "react"
112
+ * getFrameworkFamily("nuxt") → "vue"
113
+ * getFrameworkFamily("express") → "express"
114
+ */
115
+ export function getFrameworkFamily(name: string): string {
116
+ const normalized = normalizeFrameworkName(name)
117
+ return FRAMEWORK_FAMILIES[normalized] || normalized
118
+ }
119
+
120
+ /**
121
+ * Check if two tech names match (direct or via family).
122
+ *
123
+ * @example
124
+ * matchesTech("Next.js", "react") → true (Next.js is React family)
125
+ * matchesTech("React + TypeScript", "react") → true (contains react)
126
+ * matchesTech("Vue", "react") → false
127
+ */
128
+ export function matchesTech(actual: string, expected: string): boolean {
129
+ const expectedNorm = normalizeFrameworkName(expected)
130
+
131
+ // Extract all tech names from actual (handles compound names)
132
+ const actualNames = extractTechNames(actual)
133
+
134
+ for (const name of actualNames) {
135
+ // Direct match
136
+ if (name === expectedNorm) return true
137
+ // Family match
138
+ if (getFrameworkFamily(name) === expectedNorm) return true
139
+ // Reverse family match
140
+ if (getFrameworkFamily(expectedNorm) === name) return true
141
+ }
142
+
143
+ return false
144
+ }
145
+
146
+ /**
147
+ * Deduplicate a tech stack list using normalized matching.
148
+ * Preserves the first occurrence's original casing.
149
+ *
150
+ * @example
151
+ * deduplicateTechStack(["React", "react", "Next.js"]) → ["React", "Next.js"]
152
+ * deduplicateTechStack(["TypeScript", "ts"]) → ["TypeScript"]
153
+ */
154
+ export function deduplicateTechStack(stack: string[]): string[] {
155
+ const seen = new Set<string>()
156
+ const result: string[] = []
157
+
158
+ for (const name of stack) {
159
+ const normalized = normalizeFrameworkName(name)
160
+ if (!seen.has(normalized)) {
161
+ seen.add(normalized)
162
+ result.push(name)
163
+ }
164
+ }
165
+
166
+ return result
167
+ }
@@ -86,17 +86,27 @@ export interface MemoryQuery {
86
86
  }
87
87
 
88
88
  /**
89
- * Domain types for task context.
89
+ * Known domain types for task context.
90
90
  * @see PRJ-107
91
91
  */
92
- export type TaskDomain =
93
- | 'frontend'
94
- | 'backend'
95
- | 'devops'
96
- | 'docs'
97
- | 'testing'
98
- | 'database'
99
- | 'general'
92
+ export const KNOWN_DOMAINS = [
93
+ 'frontend',
94
+ 'backend',
95
+ 'devops',
96
+ 'docs',
97
+ 'testing',
98
+ 'database',
99
+ 'general',
100
+ ] as const
101
+ export type KnownDomain = (typeof KNOWN_DOMAINS)[number]
102
+
103
+ /**
104
+ * Task domain — accepts known domains with autocomplete + any string for
105
+ * semantic resolution. Unknown domains are resolved to the closest canonical
106
+ * domain via SEMANTIC_DOMAIN_KEYWORDS.
107
+ * @see PRJ-107, PRJ-300
108
+ */
109
+ export type TaskDomain = KnownDomain | (string & {})
100
110
 
101
111
  /**
102
112
  * Enhanced query parameters for selective memory retrieval.
@@ -10482,7 +10482,7 @@ function calculateConfidence(count, userConfirmed = false) {
10482
10482
  if (count >= 3) return "medium";
10483
10483
  return "low";
10484
10484
  }
10485
- var MEMORY_TAGS;
10485
+ var MEMORY_TAGS, KNOWN_DOMAINS;
10486
10486
  var init_memory = __esm({
10487
10487
  "core/types/memory.ts"() {
10488
10488
  "use strict";
@@ -10505,6 +10505,15 @@ var init_memory = __esm({
10505
10505
  CONFIRMATION_LEVEL: "confirmation_level",
10506
10506
  AGENT_PREFERENCE: "agent_preference"
10507
10507
  };
10508
+ KNOWN_DOMAINS = [
10509
+ "frontend",
10510
+ "backend",
10511
+ "devops",
10512
+ "docs",
10513
+ "testing",
10514
+ "database",
10515
+ "general"
10516
+ ];
10508
10517
  __name(calculateConfidence, "calculateConfidence");
10509
10518
  }
10510
10519
  });
@@ -10512,7 +10521,24 @@ var init_memory = __esm({
10512
10521
  // core/agentic/memory-system.ts
10513
10522
  import fs25 from "node:fs/promises";
10514
10523
  import path23 from "node:path";
10515
- var CachedStore, SessionStore, HistoryStore, PatternStore, SemanticMemories, MemorySystem, memorySystem, memory_system_default;
10524
+ function resolveCanonicalDomains(domain) {
10525
+ if (KNOWN_DOMAINS.includes(domain)) {
10526
+ return [domain];
10527
+ }
10528
+ const normalized = domain.toLowerCase().replace(/[-_\s]/g, "");
10529
+ const matches = [];
10530
+ for (const [canonical, keywords] of Object.entries(SEMANTIC_DOMAIN_KEYWORDS)) {
10531
+ if (canonical === "general") continue;
10532
+ for (const kw of keywords) {
10533
+ if (normalized.includes(kw) || kw.includes(normalized)) {
10534
+ matches.push(canonical);
10535
+ break;
10536
+ }
10537
+ }
10538
+ }
10539
+ return matches.length > 0 ? matches : ["general"];
10540
+ }
10541
+ var DOMAIN_TAG_MAP, SEMANTIC_DOMAIN_KEYWORDS, CachedStore, SessionStore, HistoryStore, PatternStore, SemanticMemories, MemorySystem, memorySystem, memory_system_default;
10516
10542
  var init_memory_system = __esm({
10517
10543
  "core/agentic/memory-system.ts"() {
10518
10544
  "use strict";
@@ -10524,6 +10550,121 @@ var init_memory_system = __esm({
10524
10550
  init_jsonl_helper();
10525
10551
  init_memory();
10526
10552
  init_memory();
10553
+ DOMAIN_TAG_MAP = {
10554
+ frontend: [
10555
+ MEMORY_TAGS.CODE_STYLE,
10556
+ MEMORY_TAGS.FILE_STRUCTURE,
10557
+ MEMORY_TAGS.ARCHITECTURE,
10558
+ MEMORY_TAGS.TECH_STACK
10559
+ ],
10560
+ backend: [
10561
+ MEMORY_TAGS.CODE_STYLE,
10562
+ MEMORY_TAGS.ARCHITECTURE,
10563
+ MEMORY_TAGS.DEPENDENCIES,
10564
+ MEMORY_TAGS.TECH_STACK
10565
+ ],
10566
+ devops: [
10567
+ MEMORY_TAGS.SHIP_WORKFLOW,
10568
+ MEMORY_TAGS.TEST_BEHAVIOR,
10569
+ MEMORY_TAGS.DEPENDENCIES,
10570
+ MEMORY_TAGS.ARCHITECTURE
10571
+ ],
10572
+ docs: [MEMORY_TAGS.CODE_STYLE, MEMORY_TAGS.NAMING_CONVENTION, MEMORY_TAGS.FILE_STRUCTURE],
10573
+ testing: [MEMORY_TAGS.TEST_BEHAVIOR, MEMORY_TAGS.CODE_STYLE, MEMORY_TAGS.DEPENDENCIES],
10574
+ database: [
10575
+ MEMORY_TAGS.ARCHITECTURE,
10576
+ MEMORY_TAGS.NAMING_CONVENTION,
10577
+ MEMORY_TAGS.TECH_STACK,
10578
+ MEMORY_TAGS.DEPENDENCIES
10579
+ ],
10580
+ general: Object.values(MEMORY_TAGS)
10581
+ };
10582
+ SEMANTIC_DOMAIN_KEYWORDS = {
10583
+ frontend: [
10584
+ "ui",
10585
+ "ux",
10586
+ "uxui",
10587
+ "css",
10588
+ "styling",
10589
+ "component",
10590
+ "layout",
10591
+ "design",
10592
+ "responsive",
10593
+ "react",
10594
+ "vue",
10595
+ "svelte",
10596
+ "angular",
10597
+ "html",
10598
+ "tailwind",
10599
+ "sass",
10600
+ "web",
10601
+ "accessibility",
10602
+ "a11y"
10603
+ ],
10604
+ backend: [
10605
+ "api",
10606
+ "server",
10607
+ "route",
10608
+ "endpoint",
10609
+ "rest",
10610
+ "graphql",
10611
+ "middleware",
10612
+ "worker",
10613
+ "queue",
10614
+ "auth",
10615
+ "hono",
10616
+ "express",
10617
+ "service",
10618
+ "microservice"
10619
+ ],
10620
+ devops: [
10621
+ "ci",
10622
+ "cd",
10623
+ "docker",
10624
+ "kubernetes",
10625
+ "deploy",
10626
+ "infra",
10627
+ "infrastructure",
10628
+ "monitoring",
10629
+ "cloud",
10630
+ "aws",
10631
+ "gcp",
10632
+ "azure",
10633
+ "pipeline",
10634
+ "helm",
10635
+ "terraform"
10636
+ ],
10637
+ docs: ["documentation", "readme", "guide", "tutorial", "wiki", "changelog", "jsdoc", "typedoc"],
10638
+ testing: [
10639
+ "test",
10640
+ "spec",
10641
+ "e2e",
10642
+ "unit",
10643
+ "integration",
10644
+ "coverage",
10645
+ "mock",
10646
+ "vitest",
10647
+ "jest",
10648
+ "playwright",
10649
+ "cypress"
10650
+ ],
10651
+ database: [
10652
+ "db",
10653
+ "sql",
10654
+ "schema",
10655
+ "migration",
10656
+ "query",
10657
+ "orm",
10658
+ "prisma",
10659
+ "mongo",
10660
+ "postgres",
10661
+ "redis",
10662
+ "drizzle",
10663
+ "sqlite"
10664
+ ],
10665
+ general: []
10666
+ };
10667
+ __name(resolveCanonicalDomains, "resolveCanonicalDomains");
10527
10668
  CachedStore = class {
10528
10669
  static {
10529
10670
  __name(this, "CachedStore");
@@ -11101,9 +11242,7 @@ var init_memory_system = __esm({
11101
11242
  userTriggered: 0
11102
11243
  };
11103
11244
  if (query.taskDomain) {
11104
- const domainTags = this._getDomainTags(query.taskDomain);
11105
- const matchingTags = (memory.tags || []).filter((tag) => domainTags.includes(tag));
11106
- breakdown.domainMatch = Math.min(25, matchingTags.length * 10);
11245
+ breakdown.domainMatch = this._getSemanticDomainScore(query.taskDomain, memory.tags || []);
11107
11246
  }
11108
11247
  if (query.commandName) {
11109
11248
  const commandTags = this._getCommandTags(query.commandName);
@@ -11153,25 +11292,55 @@ var init_memory_system = __esm({
11153
11292
  };
11154
11293
  }
11155
11294
  /**
11156
- * Map task domain to relevant memory tags.
11157
- * @see PRJ-107
11295
+ * Compute semantic domain match score (0-25 points).
11296
+ *
11297
+ * Two-pass scoring:
11298
+ * 1. Exact MEMORY_TAG match: memory tag in domain's tag list → 10 pts each
11299
+ * 2. Semantic match: memory tag relates to domain via keywords → 5 pts each
11300
+ *
11301
+ * Unknown domains are resolved to canonical domain(s) via SEMANTIC_DOMAIN_KEYWORDS.
11302
+ * @see PRJ-107, PRJ-300
11303
+ */
11304
+ _getSemanticDomainScore(domain, memoryTags) {
11305
+ const canonicals = this._resolveCanonicalDomains(domain);
11306
+ if (canonicals.length === 0) return 0;
11307
+ const relevantTags = /* @__PURE__ */ new Set();
11308
+ for (const canonical of canonicals) {
11309
+ const tags = DOMAIN_TAG_MAP[canonical];
11310
+ if (tags) {
11311
+ for (const tag of tags) relevantTags.add(tag);
11312
+ }
11313
+ }
11314
+ const domainKeywords = /* @__PURE__ */ new Set();
11315
+ for (const canonical of canonicals) {
11316
+ const keywords = SEMANTIC_DOMAIN_KEYWORDS[canonical];
11317
+ if (keywords) {
11318
+ for (const kw of keywords) domainKeywords.add(kw);
11319
+ }
11320
+ }
11321
+ let score = 0;
11322
+ for (const tag of memoryTags) {
11323
+ if (relevantTags.has(tag)) {
11324
+ score += 10;
11325
+ continue;
11326
+ }
11327
+ const normalized = tag.toLowerCase().replace(/[-_\s]/g, "");
11328
+ for (const kw of domainKeywords) {
11329
+ if (normalized.includes(kw) || kw.includes(normalized)) {
11330
+ score += 5;
11331
+ break;
11332
+ }
11333
+ }
11334
+ }
11335
+ return Math.min(25, score);
11336
+ }
11337
+ /**
11338
+ * Resolve a domain string to canonical known domain(s).
11339
+ * Delegates to module-level resolveCanonicalDomains().
11340
+ * @see PRJ-300
11158
11341
  */
11159
- _getDomainTags(domain) {
11160
- const domainTagMap = {
11161
- frontend: [MEMORY_TAGS.CODE_STYLE, MEMORY_TAGS.FILE_STRUCTURE, MEMORY_TAGS.ARCHITECTURE],
11162
- backend: [
11163
- MEMORY_TAGS.CODE_STYLE,
11164
- MEMORY_TAGS.ARCHITECTURE,
11165
- MEMORY_TAGS.DEPENDENCIES,
11166
- MEMORY_TAGS.TECH_STACK
11167
- ],
11168
- devops: [MEMORY_TAGS.SHIP_WORKFLOW, MEMORY_TAGS.TEST_BEHAVIOR, MEMORY_TAGS.DEPENDENCIES],
11169
- docs: [MEMORY_TAGS.CODE_STYLE, MEMORY_TAGS.NAMING_CONVENTION],
11170
- testing: [MEMORY_TAGS.TEST_BEHAVIOR, MEMORY_TAGS.CODE_STYLE],
11171
- database: [MEMORY_TAGS.ARCHITECTURE, MEMORY_TAGS.NAMING_CONVENTION],
11172
- general: Object.values(MEMORY_TAGS)
11173
- };
11174
- return domainTagMap[domain] || [];
11342
+ _resolveCanonicalDomains(domain) {
11343
+ return resolveCanonicalDomains(domain);
11175
11344
  }
11176
11345
  /**
11177
11346
  * Map command to relevant memory tags.
@@ -15894,28 +16063,71 @@ var init_outcomes2 = __esm({
15894
16063
  }
15895
16064
  });
15896
16065
 
16066
+ // core/agentic/tech-normalizer.ts
16067
+ function normalizeFrameworkName(name) {
16068
+ const trimmed = name.trim().toLowerCase();
16069
+ const aliasKey = trimmed.replace(/[.\s-]/g, "");
16070
+ if (FRAMEWORK_ALIASES[aliasKey]) {
16071
+ return FRAMEWORK_ALIASES[aliasKey];
16072
+ }
16073
+ if (FRAMEWORK_ALIASES[trimmed]) {
16074
+ return FRAMEWORK_ALIASES[trimmed];
16075
+ }
16076
+ return trimmed;
16077
+ }
16078
+ function deduplicateTechStack(stack) {
16079
+ const seen = /* @__PURE__ */ new Set();
16080
+ const result = [];
16081
+ for (const name of stack) {
16082
+ const normalized = normalizeFrameworkName(name);
16083
+ if (!seen.has(normalized)) {
16084
+ seen.add(normalized);
16085
+ result.push(name);
16086
+ }
16087
+ }
16088
+ return result;
16089
+ }
16090
+ var FRAMEWORK_ALIASES;
16091
+ var init_tech_normalizer = __esm({
16092
+ "core/agentic/tech-normalizer.ts"() {
16093
+ "use strict";
16094
+ FRAMEWORK_ALIASES = {
16095
+ nextjs: "next.js",
16096
+ nuxtjs: "nuxt.js",
16097
+ expressjs: "express",
16098
+ fastifyjs: "fastify",
16099
+ "react.js": "react",
16100
+ "vue.js": "vue",
16101
+ "svelte.js": "svelte",
16102
+ "angular.js": "angular",
16103
+ angularjs: "angular",
16104
+ "node.js": "node",
16105
+ nodejs: "node",
16106
+ ts: "typescript",
16107
+ js: "javascript",
16108
+ pg: "postgres",
16109
+ postgresql: "postgres",
16110
+ mongo: "mongodb"
16111
+ };
16112
+ __name(normalizeFrameworkName, "normalizeFrameworkName");
16113
+ __name(deduplicateTechStack, "deduplicateTechStack");
16114
+ }
16115
+ });
16116
+
15897
16117
  // core/agentic/anti-hallucination.ts
15898
16118
  import { z as z16 } from "zod";
15899
16119
  function buildAntiHallucinationBlock(truth) {
15900
16120
  const parts = [];
15901
16121
  parts.push("## CONSTRAINTS (Read Before Acting)\n");
15902
- const available = [];
15903
- if (truth.language) available.push(truth.language);
15904
- if (truth.framework) available.push(truth.framework);
16122
+ const rawAvailable = [];
16123
+ if (truth.language) rawAvailable.push(truth.language);
16124
+ if (truth.framework) rawAvailable.push(truth.framework);
15905
16125
  const techStack = truth.techStack ?? [];
15906
- available.push(...techStack.filter((t) => t !== truth.framework));
16126
+ rawAvailable.push(...techStack);
15907
16127
  const analysisLangs = truth.analysisLanguages ?? [];
15908
16128
  const analysisFrameworks = truth.analysisFrameworks ?? [];
15909
- for (const lang of analysisLangs) {
15910
- if (!available.some((a) => a.toLowerCase() === lang.toLowerCase())) {
15911
- available.push(lang);
15912
- }
15913
- }
15914
- for (const fw of analysisFrameworks) {
15915
- if (!available.some((a) => a.toLowerCase() === fw.toLowerCase())) {
15916
- available.push(fw);
15917
- }
15918
- }
16129
+ rawAvailable.push(...analysisLangs, ...analysisFrameworks);
16130
+ const available = deduplicateTechStack(rawAvailable);
15919
16131
  if (available.length > 0) {
15920
16132
  parts.push(`AVAILABLE in this project: ${available.join(", ")}`);
15921
16133
  }
@@ -15948,6 +16160,7 @@ var ProjectGroundTruthSchema, DOMAIN_LABELS;
15948
16160
  var init_anti_hallucination = __esm({
15949
16161
  "core/agentic/anti-hallucination.ts"() {
15950
16162
  "use strict";
16163
+ init_tech_normalizer();
15951
16164
  ProjectGroundTruthSchema = z16.object({
15952
16165
  /** Project root path */
15953
16166
  projectPath: z16.string(),
@@ -16375,6 +16588,7 @@ var init_prompt_builder = __esm({
16375
16588
  init_command_context2();
16376
16589
  init_environment_block();
16377
16590
  init_injection_validator();
16591
+ init_tech_normalizer();
16378
16592
  PromptBuilder = class {
16379
16593
  static {
16380
16594
  __name(this, "PromptBuilder");
@@ -16870,10 +17084,15 @@ Show changes, list affected files, ask for confirmation.
16870
17084
  }
16871
17085
  if (projectPath) {
16872
17086
  const sa = orchestratorContext?.sealedAnalysis;
17087
+ const rawStack = [
17088
+ ...sa?.frameworks || [],
17089
+ ...orchestratorContext?.project?.conventions || []
17090
+ ];
16873
17091
  const groundTruth2 = {
16874
17092
  projectPath,
16875
17093
  language: orchestratorContext?.project?.ecosystem,
16876
- techStack: orchestratorContext?.project?.conventions || [],
17094
+ framework: sa?.frameworks?.[0],
17095
+ techStack: deduplicateTechStack(rawStack),
16877
17096
  domains: this.extractDomains(state),
16878
17097
  fileCount: context2.files?.length || context2.filteredSize || 0,
16879
17098
  availableAgents: orchestratorContext?.agents?.map((a) => a.name) || [],
@@ -31842,7 +32061,7 @@ var require_package = __commonJS({
31842
32061
  "package.json"(exports, module) {
31843
32062
  module.exports = {
31844
32063
  name: "prjct-cli",
31845
- version: "1.14.0",
32064
+ version: "1.15.0",
31846
32065
  description: "Context layer for AI agents. Project context for Claude Code, Gemini CLI, and more.",
31847
32066
  main: "core/index.ts",
31848
32067
  bin: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prjct-cli",
3
- "version": "1.14.0",
3
+ "version": "1.15.0",
4
4
  "description": "Context layer for AI agents. Project context for Claude Code, Gemini CLI, and more.",
5
5
  "main": "core/index.ts",
6
6
  "bin": {